Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen
Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung. Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
This commit is contained in:
1 parent
42c87282ee
commit
363bc27c26
190 files changed
+14815
-1857
No files matched your search
@@ -0,0 +1,92 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Address-to-tariff mappings from srvpa_list.php and srvpaa_list.php. */
|
||||
@Service
|
||||
class AddressAdministration {
|
||||
enum Kind {
|
||||
POSTAL("serviceplzaddress","srvpxa","srvp_id","serviceplz","srvp_plz"),
|
||||
AREA("serviceplzareaaddress","srvpaa","srvpa_id","serviceplzarea","srvpa_name");
|
||||
final String table,prefix,target,targetTable,label;
|
||||
Kind(String table,String prefix,String target,String targetTable,String label){this.table=table;this.prefix=prefix;this.target=target;this.targetTable=targetTable;this.label=label;}
|
||||
}
|
||||
record Query(String target,String street,String postalCode,String city,String country,String from,String to,Integer mode,String sort,int page){}
|
||||
record Save(String requestId,String target,String street,String postalCode,String city,String country,String from,String to,int mode){}
|
||||
record Selection(long id,String revision){}
|
||||
record Delete(String requestId,List<Selection> items){}
|
||||
record Operation(String kind,long hq,long user,String request,List<Map<String,Object>> rows){}
|
||||
private final DatabaseService db;
|
||||
private final ObjectMapper json;
|
||||
private final OrderEntryCatalog catalog;
|
||||
AddressAdministration(DatabaseService db,ObjectMapper json,OrderEntryCatalog catalog){this.db=db;this.json=json;this.catalog=catalog;}
|
||||
void access(LegacyUser u){u.require(2);}
|
||||
static String clean(String value,int max){value=Objects.requireNonNullElse(value,"").strip();if(value.length()>max)throw bad("Such- oder Eingabefeld ist zu lang.");return value;}
|
||||
static String like(String value){return value.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
|
||||
String targetName(LegacyUser u,Kind k,String value){value=clean(value,10);if(k==Kind.AREA&&!value.isEmpty()){
|
||||
String pad=catalog.parameter(u.headquartersId(),"ZIPCODEAREA_PADLENGTH");
|
||||
if(pad.matches("[1-9]|10"))value="0".repeat(Math.max(0,Integer.parseInt(pad)-value.length()))+value;
|
||||
}return value;}
|
||||
String select(Kind k){return "SELECT m."+k.prefix+"_id AS id,m.hq_id,m."+k.target+" AS targetId,m.ad_id,m."+k.prefix+"_hsno_from AS houseFrom,m."+k.prefix+"_hsno_to AS houseTo,m."+k.prefix+"_mode AS mode,t."+k.label+" AS target,a.ad_street AS street,a.ad_zipcode AS postalCode,a.ad_city AS city,a.ad_country AS country"+from(k);}
|
||||
String from(Kind k){return " FROM "+k.table+" m JOIN address a ON a.ad_id=m.ad_id JOIN "+k.targetTable+" t ON t."+k.target+"=m."+k.target+" WHERE m.hq_id=?"+(k==Kind.AREA?" AND t.hq_id=m.hq_id":"");}
|
||||
Map<String,Object> query(LegacyUser u,Kind k,Query q){access(u);if(q==null||q.page()<0||q.page()>100000)throw bad("Ungültige Suchanfrage.");
|
||||
var args=new ArrayList<Object>();args.add(u.headquartersId());StringBuilder where=new StringBuilder();
|
||||
String target=targetName(u,k,q.target());if(!target.isEmpty()){where.append(" AND t.").append(k.label).append("=?");args.add(target);}
|
||||
String[] values={q.street(),q.postalCode(),q.city(),q.country()};String[] columns={"ad_street","ad_zipcode","ad_city","ad_country"};int[] lengths={50,10,50,10};
|
||||
for(int i=0;i<values.length;i++){String v=clean(values[i],lengths[i]);if(!v.isEmpty()){where.append(" AND a.").append(columns[i]).append(" LIKE ? ESCAPE '!'");args.add(like(v));}}
|
||||
for(var entry:Map.of("from",clean(q.from(),4),"to",clean(q.to(),4)).entrySet())if(!entry.getValue().isEmpty()){where.append(" AND m.").append(k.prefix).append("_hsno_").append(entry.getKey()).append("=?");args.add(entry.getValue());}
|
||||
if(q.mode()!=null){if(q.mode()<0||q.mode()>2)throw bad("Ungültiger Hausnummernmodus.");where.append(" AND m.").append(k.prefix).append("_mode=?");args.add(q.mode());}
|
||||
String sort=switch(Objects.requireNonNullElse(q.sort(),"street")){case "target"->"t."+k.label;case "from"->"m."+k.prefix+"_hsno_from";case "to"->"m."+k.prefix+"_hsno_to";case "mode"->"m."+k.prefix+"_mode";case "postalCode"->"a.ad_zipcode";case "city"->"a.ad_city";case "street"->"a.ad_street";default->throw bad("Ungültige Sortierung.");};
|
||||
long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*)"+from(k)+where,Long.class,args.toArray())).orElse(0L);
|
||||
args.add(q.page()*100);var rows=db.queryForList(select(k)+where+" ORDER BY "+sort+",m."+k.prefix+"_id LIMIT 100 OFFSET ?",args.toArray());
|
||||
var items=new ArrayList<Map<String,Object>>();for(var r:rows){var v=new LinkedHashMap<>(r);v.put("revision",revision(r));v.put("exceptions",k==Kind.AREA?exceptions(u,id(r,"id")):List.of());items.add(v);}
|
||||
return fields("items",items,"total",total);
|
||||
}
|
||||
List<Map<String,Object>> exceptions(LegacyUser u,long id){
|
||||
var result=new ArrayList<Map<String,Object>>();
|
||||
for(var row:db.queryForList("SELECT par_value FROM parameter WHERE par_key=? AND hq_id IN (0,?) AND emp_id=0","MASK_AREA_ID_FROM_ADDRESS_"+id,u.headquartersId()))
|
||||
for(String pair:text(row,"par_value").split(";")){String[] parts=pair.split(",");if(parts.length!=2||!parts[1].matches("[0-9]{1,9}"))continue;var targets=db.queryForList("SELECT srvpa_name FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",Long.parseLong(parts[1]),u.headquartersId());result.add(fields("house",parts[0],"area",targets.isEmpty()?parts[1]:text(targets.getFirst(),"srvpa_name")));}
|
||||
return result;
|
||||
}
|
||||
Map<String,Object> options(LegacyUser u,Kind k){access(u);return fields("headquartersId",u.headquartersId(),"operations",db.queryForList("SELECT gdc_id AS id,SUBSTRING(gdc_gen_fieldname,9) AS token FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND LEFT(gdc_gen_fieldname,8)='ng_addr_' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 100",k.prefix,u.headquartersId()));}
|
||||
static Integer house(String value){String v=clean(value,4);if(v.isEmpty())return null;if(!v.matches("[0-9]{1,4}"))throw bad("Hausnummerngrenzen müssen ganze Zahlen von 0 bis 9999 sein.");return Integer.parseInt(v);}
|
||||
long targetId(LegacyUser u,Kind k,String value,String country){var rows=db.queryForList("SELECT "+k.target+" AS id FROM "+k.targetTable+" WHERE "+k.label+"=?"+(k==Kind.AREA?" AND hq_id=?":" AND COALESCE(NULLIF(srvp_country,''),'DE')=?"),targetName(u,k,value),k==Kind.AREA?u.headquartersId():country);if(rows.size()!=1)throw bad("Abrechnungs-PLZ bzw. Bereich existiert nicht eindeutig in der ausgewählten Niederlassung / im Land.");return id(rows.getFirst(),"id");}
|
||||
String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("Vorgang konnte nicht gesichert werden.",e);}}
|
||||
Operation decode(Map<String,Object> row){try{return json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw bad("Gespeicherter Adressvorgang ist beschädigt.");}}
|
||||
Map<String,Object> operation(LegacyUser u,Kind k,String token,boolean lock){String sql="SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type=? AND gdc_obj_id=? AND gdc_gen_fieldname=?";var rows=lock?db.lockRows(sql,k.prefix,u.headquartersId(),"ng_addr_"+token):db.queryForList(sql,k.prefix,u.headquartersId(),"ng_addr_"+token);if(rows.size()>1)throw bad("Mehrdeutige Vorgangskennung.");return rows.isEmpty()?null:rows.getFirst();}
|
||||
void persist(LegacyUser u,Kind k,String token,Operation op){String content=encode(op);if(content.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Bitte weniger Zuordnungen auf einmal auswählen.");db.insert("genericdatacontainer",fields("gdc_obj_type",k.prefix,"gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname","ng_addr_"+token,"gdc_content",content,"gdc_context","PENDING"));}
|
||||
boolean repeated(Map<String,Object> row,LegacyUser u,String request){if(row==null)return false;var op=decode(row);if(op.user()!=u.userId()||!op.request().equals(request))throw bad("Vorgangskennung wurde bereits für eine andere Änderung verwendet.");return true;}
|
||||
Map<String,Object> create(LegacyUser u,Kind k,Save s){access(u);if(s==null)throw bad("Adressdaten fehlen.");String token=FilterAdministration.token(s.requestId());String request=encode(s);
|
||||
String street=required(s.street(),50,"Straße"),postal=required(s.postalCode(),10,"PLZ"),country=clean(s.country(),10).toUpperCase(Locale.ROOT);if(country.isEmpty())country="DE";final String nation=country;
|
||||
Integer start=house(s.from()),end=house(s.to());if(start!=null&&end!=null&&start>end)throw bad("Hausnummer von darf nicht größer als bis sein.");if(s.mode()<0||s.mode()>2)throw bad("Ungültiger Hausnummernmodus.");
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);if(repeated(operation(u,k,token,true),u,request))return null;
|
||||
long target=targetId(u,k,required(s.target(),10,"Abrechnungsziel"),nation);
|
||||
if(db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? AND COALESCE(NULLIF(srvp_country,''),'DE')=?",postal,nation).isEmpty())throw bad("Die Straßen-PLZ existiert nicht im gewählten Land.");
|
||||
String city=clean(s.city(),50);if(city.isEmpty()){var cities=db.queryForList("SELECT DISTINCT ad_city FROM address WHERE ad_zipcode=? AND ad_country=? AND ad_city<>'' ORDER BY ad_city LIMIT 2",postal,nation);if(cities.size()==1)city=text(cities.getFirst(),"ad_city");else throw bad("Bitte den Ort ergänzen; zur PLZ ist kein eindeutiger Ort hinterlegt.");}
|
||||
var addresses=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? ORDER BY ad_id LIMIT 1",street,postal,city,nation);
|
||||
long address=addresses.isEmpty()?db.insert("address",fields("ad_street",street,"ad_zipcode",postal,"ad_city",city,"ad_country",nation)):id(addresses.getFirst(),"ad_id");
|
||||
var row=fields("targetId",target,"ad_id",address,"houseFrom",start,"houseTo",end==null?null:end.toString(),"mode",s.mode());
|
||||
// Commit address and intent before touching the nontransactional association table.
|
||||
persist(u,k,token,new Operation("CREATE",u.headquartersId(),u.userId(),request,List.of(row)));return null;});
|
||||
return resume(u,k,token);
|
||||
}
|
||||
Map<String,Object> remove(LegacyUser u,Kind k,Delete s){access(u);if(s==null||s.items()==null||s.items().isEmpty()||s.items().size()>100)throw bad("Bitte 1 bis 100 Zuordnungen auswählen.");String token=FilterAdministration.token(s.requestId()),request=encode(s);
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);if(repeated(operation(u,k,token,true),u,request))return null;var rows=new ArrayList<Map<String,Object>>();var seen=new HashSet<Long>();for(var selection:s.items()){if(selection==null||!seen.add(selection.id()))throw bad("Ungültige oder doppelte Auswahl.");var r=one(db.queryForList(select(k)+" AND m."+k.prefix+"_id=?",u.headquartersId(),selection.id()));unchanged(r,selection.revision());rows.add(r);}persist(u,k,token,new Operation("DELETE",u.headquartersId(),u.userId(),request,rows));return null;});return resume(u,k,token);
|
||||
}
|
||||
String match(Kind k){return k.target+"=? AND ad_id=? AND "+k.prefix+"_hsno_from <=> ? AND "+k.prefix+"_hsno_to <=> ? AND "+k.prefix+"_mode <=> ?";}
|
||||
List<Object> args(Map<String,Object> r){return new ArrayList<>(Arrays.asList(id(r,"targetId"),id(r,"ad_id"),r.get("houseFrom"),r.get("houseTo"),r.get("mode")));}
|
||||
Map<String,Object> resume(LegacyUser u,Kind k,String token){access(u);FilterAdministration.token(token);if(operation(u,k,token,false)==null)throw bad("Adressvorgang nicht gefunden.");
|
||||
try{return db.writeTransaction(()->{lockHeadquarters(db,u);var saved=operation(u,k,token,true);if(text(saved,"gdc_context").equals("DONE"))return fields("state","DONE","message","Adressvorgang abgeschlossen.");var op=decode(saved);
|
||||
for(var r:op.rows())if(op.kind().equals("CREATE")){var parameters=args(r);parameters.add(0,op.hq());var insertArgs=new ArrayList<>(parameters);insertArgs.addAll(parameters);db.update("INSERT INTO "+k.table+" (hq_id,"+k.target+",ad_id,"+k.prefix+"_hsno_from,"+k.prefix+"_hsno_to,"+k.prefix+"_mode) SELECT ?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM "+k.table+" WHERE hq_id=? AND "+match(k)+")",insertArgs.toArray());}
|
||||
else if(op.kind().equals("DELETE")){var parameters=args(r);parameters.add(0,op.hq());parameters.add(0,id(r,"id"));int removed=db.update("DELETE FROM "+k.table+" WHERE "+k.prefix+"_id=? AND hq_id=? AND "+match(k),parameters.toArray());if(removed==0&&!db.queryForList("SELECT "+k.prefix+"_id FROM "+k.table+" WHERE "+k.prefix+"_id=?",id(r,"id")).isEmpty())throw bad("Eine Zuordnung wurde zwischenzeitlich geändert. Bereits entfernte Zuordnungen bleiben entfernt.");}
|
||||
else throw bad("Unbekannter Adressvorgang.");
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(saved,"gdc_id"));return fields("state","DONE","message",op.kind().equals("CREATE")?"Adresszuordnung gespeichert.":"Ausgewählte Zuordnungen entfernt.");});}
|
||||
catch(RuntimeException e){return fields("state","PENDING","message","Der Adressvorgang ist gesichert, aber noch nicht abgeschlossen. Bitte den vorhandenen Vorgang fortsetzen."+(e instanceof org.springframework.web.server.ResponseStatusException r&&r.getReason()!=null?" "+r.getReason():""));}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController @RequestMapping("/api/administration/addresses/{kind}")
|
||||
class AddressController {
|
||||
private final AddressAdministration service;
|
||||
AddressController(AddressAdministration service){this.service=service;}
|
||||
@GetMapping Map<String,Object> options(Authentication a,@PathVariable AddressAdministration.Kind kind){return service.options(user(a),kind);}
|
||||
@PostMapping("/query") Map<String,Object> query(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Query q){return service.query(user(a),kind,q);}
|
||||
@PostMapping Map<String,Object> create(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Save s){return service.create(user(a),kind,s);}
|
||||
@PostMapping("/remove") Map<String,Object> remove(Authentication a,@PathVariable AddressAdministration.Kind kind,@RequestBody AddressAdministration.Delete s){return service.remove(user(a),kind,s);}
|
||||
@PostMapping("/operations/{token}/resume") Map<String,Object> resume(Authentication a,@PathVariable AddressAdministration.Kind kind,@PathVariable String token){return service.resume(user(a),kind,token);}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Shared validation for the explicitly ported administration workflows. */
|
||||
final class AdminSupport {
|
||||
private AdminSupport() {}
|
||||
static String revision(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private static String canonical(Object value) {
|
||||
if(value instanceof Map<?,?> map) {
|
||||
var sorted=new TreeMap<String,String>();map.forEach((k,v)->sorted.put(String.valueOf(k),canonical(v)));
|
||||
return sorted.toString();
|
||||
}
|
||||
if(value instanceof Collection<?> list)return list.stream().map(AdminSupport::canonical).toList().toString();
|
||||
return value==null?"<null>":value.getClass().getSimpleName()+":"+value;
|
||||
}
|
||||
static void unchanged(Object current,String expected) {
|
||||
if(expected==null || !revision(current).equals(expected))throw new ResponseStatusException(HttpStatus.CONFLICT,"Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderungen prüfen.");
|
||||
}
|
||||
static String required(String value,int max,String label) {
|
||||
value=value==null?"":value.strip();
|
||||
if(value.isEmpty() || value.length()>max)throw bad(label+": Bitte 1 bis "+max+" Zeichen eingeben.");
|
||||
return value;
|
||||
}
|
||||
static void range(LocalDate from,LocalDate to) {
|
||||
if(from==null || to==null || from.isAfter(to) || from.plusYears(10).isBefore(to))throw bad("Bitte einen gültigen Zeitraum von höchstens zehn Jahren wählen.");
|
||||
}
|
||||
static Map<String,Object> one(List<Map<String,Object>> rows) {
|
||||
if(rows.size()!=1)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Der Datensatz ist nicht vorhanden oder nicht freigegeben.");
|
||||
return rows.getFirst();
|
||||
}
|
||||
static void lockHeadquarters(DatabaseService db,LegacyUser user) {
|
||||
one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",user.headquartersId()));
|
||||
}
|
||||
static long mandator(DatabaseService db,LegacyUser user) {
|
||||
var rows=db.queryForList("SELECT md_id FROM mandatorheadquarters WHERE hq_id=? AND md_id>0",user.headquartersId());
|
||||
return id(one(rows),"md_id");
|
||||
}
|
||||
static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.CalendarAdministration.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/administration")
|
||||
public class AdministrationController {
|
||||
private final CalendarAdministration calendar;
|
||||
private final GroupAdministration groups;
|
||||
private final GroupDeletion deletion;
|
||||
public AdministrationController(CalendarAdministration calendar,GroupAdministration groups,GroupDeletion deletion){this.calendar=calendar;this.groups=groups;this.deletion=deletion;}
|
||||
@GetMapping Map<String,Object> sections(Authentication auth) {
|
||||
var u=user(auth);
|
||||
return OrderEntryService.fields("postalPrices",u.hasRight(2),"postalDiscounts",u.hasRight(2),"areaPrices",u.hasRight(2),"areaDiscounts",u.hasRight(2),"servicePrices",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"serviceDiscounts",u.hasRight(2)&&(u.hasRight(29)||u.hasRight(0)),"postalAddresses",u.hasRight(2),"areaAddresses",u.hasRight(2),"headquartersId",u.headquartersId(),"holidays",u.hasRight(2),"travelTimes",u.hasRight(2),"groups",u.hasRight(8)&&u.hasRight(25),"sharedGroups",u.hasRight(10),"evaluations",u.hasRight(0)&&u.hasRight(8),"filters",u.hasRight(0),"customerReports",u.hasRight(0)&&u.hasRight(16),"courierReports",u.hasRight(1)&&u.hasRight(16),"groupKinds",Arrays.stream(GroupAdministration.Kind.values()).filter(k->u.hasRight(k.right)&&(k.hq!=null||u.hasRight(10))).map(k->Map.of("id",k.name(),"label",k.label)).toList());
|
||||
}
|
||||
@GetMapping("/holidays/{year}") Map<String,Object> holidays(Authentication auth,@PathVariable int year){return calendar.holidays(user(auth),year);}
|
||||
@PutMapping("/holidays/{year}") Map<String,Object> holidays(Authentication auth,@PathVariable int year,@RequestBody HolidaySave request){return calendar.saveHolidays(user(auth),year,request);}
|
||||
@GetMapping("/travel-times") Map<String,Object> travel(Authentication auth,@RequestParam TravelMode mode,@RequestParam String from,@RequestParam String to){return calendar.travelTimes(user(auth),mode,from,to);}
|
||||
@PutMapping("/travel-times/{id}") Map<String,Object> travel(Authentication auth,@PathVariable long id,@RequestParam TravelMode mode,@RequestBody TravelSave request){return calendar.saveTravelTime(user(auth),mode,id,request);}
|
||||
@GetMapping("/groups") List<Map<String,Object>> groups(Authentication auth,@RequestParam(required=false) Long headquartersId){return groups.list(groups.scoped(user(auth),headquartersId));}
|
||||
@PostMapping("/groups") Map<String,Object> createGroup(Authentication auth,@RequestParam(required=false) Long headquartersId,@RequestBody GroupAdministration.GroupSave request){return groups.save(groups.scoped(user(auth),headquartersId),0,request);}
|
||||
@PutMapping("/groups/{id}") Map<String,Object> saveGroup(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestBody GroupAdministration.GroupSave request){return groups.save(groups.scoped(user(auth),headquartersId),id,request);}
|
||||
record Revision(String revision) {}
|
||||
@GetMapping("/groups/headquarters") Object groupHeadquarters(Authentication auth){return groups.headquarters(user(auth));}
|
||||
@GetMapping("/groups/operations") Object groupOperations(Authentication auth,@RequestParam(required=false) Long headquartersId){return deletion.pending(groups.scoped(user(auth),headquartersId));}
|
||||
@GetMapping("/groups/{id}/deletion") Object groupDeletion(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId){return deletion.preview(groups.scoped(user(auth),headquartersId),id);}
|
||||
@DeleteMapping("/groups/{id}") Object removeGroup(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestBody GroupDeletion.Delete request){return deletion.remove(groups.scoped(user(auth),headquartersId),id,request);}
|
||||
@PostMapping("/groups/operations/{token}") Object resumeGroupDeletion(Authentication auth,@PathVariable String token,@RequestParam(required=false) Long headquartersId){return deletion.resume(groups.scoped(user(auth),headquartersId),token);}
|
||||
@GetMapping("/groups/{id}/members") Map<String,Object> members(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@RequestParam GroupAdministration.Kind kind,@RequestParam(defaultValue="") String q,@RequestParam(defaultValue="true") boolean onlyMembers){return groups.members(groups.scoped(user(auth),headquartersId),id,kind,q,onlyMembers);}
|
||||
@PutMapping("/groups/{id}/members/{kind}/{objectId}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void membership(Authentication auth,@PathVariable long id,@RequestParam(required=false) Long headquartersId,@PathVariable GroupAdministration.Kind kind,@PathVariable long objectId,@RequestBody GroupAdministration.MembershipSave request){groups.membership(groups.scoped(user(auth),headquartersId),id,kind,objectId,request);}
|
||||
}
|
||||
@@ -38,11 +38,11 @@ public class ApiController {
|
||||
boolean connected=connection.connected();
|
||||
LegacyUser u=authentication!=null&&authentication.getPrincipal() instanceof LegacyUser lu?lu:null;
|
||||
Map<String,Object> result=new LinkedHashMap<>();
|
||||
result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",true);result.put("schemaChanges",false);
|
||||
result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",u==null||!(u.hasRight(0)||u.hasRight(1)||u.hasRight(7)||u.hasRight(2)||u.hasRight(4)||u.hasRight(18)||u.hasRight(15)||(u.hasRight(0)&&u.hasRight(32))||(u.hasRight(0)&&u.hasRight(8))||(u.hasRight(0)&&u.hasRight(17))||(u.hasRight(0)&&u.hasRight(11))||(u.hasRight(8)&&u.hasRight(25))||(u.hasRight(16)&&(u.hasRight(0)||u.hasRight(1)))));result.put("schemaChanges",false);
|
||||
result.put("message",connection.message());result.put("reason",connection.reason());
|
||||
result.put("legacyUrl",legacyUrl);result.put("maxResults",legacy.maxResults());
|
||||
result.put("capabilities",u==null?new Capabilities(false,false,false,false,false,false,false,false,false,false):u.capabilities());
|
||||
result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Archivtabellen"));
|
||||
result.put("unavailableModules",List.of("Fibu-Zahlungsabgleich","Archivtabellen"));
|
||||
return result;
|
||||
}
|
||||
@GetMapping("/me") public Map<String,Object> me(Authentication authentication) {
|
||||
@@ -57,7 +57,7 @@ public class ApiController {
|
||||
}
|
||||
@GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); }
|
||||
@GetMapping("/orders/{id}/history") public List<History> history(Authentication auth,@PathVariable long id) { return history.history(user(auth),id); }
|
||||
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); }
|
||||
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q,@RequestParam(defaultValue="false") boolean showCostCenters,@RequestParam(required=false) String costCenterName) { return legacy.customers(user(auth),q,showCostCenters,costCenterName); }
|
||||
@GetMapping("/couriers") public List<Courier> couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); }
|
||||
@GetMapping("/orders/export.csv") public ResponseEntity<byte[]> csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
|
||||
LegacyUser u=user(auth);u.require(5);
|
||||
|
||||
@@ -25,6 +25,15 @@ public class ApiErrors {
|
||||
ResponseEntity<?> status(ResponseStatusException exception) { return ResponseEntity.status(exception.getStatusCode()).body(body(exception.getReason()==null?"Die Anfrage konnte nicht verarbeitet werden.":exception.getReason())); }
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
ResponseEntity<?> argument(MethodArgumentTypeMismatchException exception) { return ResponseEntity.badRequest().body(body("Ungültiger Parameter: "+exception.getName())); }
|
||||
@ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class)
|
||||
ResponseEntity<?> validation(org.springframework.web.bind.MethodArgumentNotValidException exception) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message","Bitte die Eingaben prüfen.","errors",exception.getBindingResult().getFieldErrors().stream()
|
||||
.map(error -> error.getField()+": "+error.getDefaultMessage()).distinct().toList()));
|
||||
}
|
||||
@ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException.class)
|
||||
ResponseEntity<?> malformed(org.springframework.http.converter.HttpMessageNotReadableException exception) {
|
||||
return ResponseEntity.badRequest().body(body("Ungültige Eingaben. Bitte Feldwerte, Datum und Uhrzeit prüfen."));
|
||||
}
|
||||
@ExceptionHandler(Exception.class)
|
||||
ResponseEntity<?> unknown(Exception exception) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body("Die Anfrage konnte nicht verarbeitet werden. Bitte die Konfiguration des Legacy-Adapters prüfen.")); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.AppointmentService.*;
|
||||
@RestController
|
||||
@RequestMapping("/api/appointments")
|
||||
public class AppointmentController {
|
||||
private final AppointmentService service;
|
||||
public AppointmentController(AppointmentService service) { this.service=service; }
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication auth) { return service.options(user(auth)); }
|
||||
@GetMapping("/customers") List<Map<String,Object>> customers(Authentication auth,@RequestParam(defaultValue="") String q) { return service.customers(user(auth),q); }
|
||||
@PostMapping("/query") Map<String,Object> list(Authentication auth,@RequestBody Query query) { return service.list(user(auth),query); }
|
||||
@PostMapping("/statistics") Map<String,Object> statistics(Authentication auth,@RequestBody Query query) { return service.statistics(user(auth),query); }
|
||||
@PostMapping Map<String,Object> create(Authentication auth,@RequestBody Save request) { return service.create(user(auth),request); }
|
||||
@PutMapping("/{id}") Map<String,Object> save(Authentication auth,@PathVariable long id,@RequestBody Save request) { return service.save(user(auth),id,request); }
|
||||
@PostMapping("/{id}/resubmit") Map<String,Object> resubmit(Authentication auth,@PathVariable long id,@RequestBody Resubmission request) { return service.resubmit(user(auth),id,request); }
|
||||
record Revision(String revision) {}
|
||||
@DeleteMapping("/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.remove(user(auth),id,request.revision()); }
|
||||
@PostMapping("/{id}/confirm") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void confirm(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.confirm(user(auth),id,request.revision()); }
|
||||
@PostMapping("/{id}/finish") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void finish(Authentication auth,@PathVariable long id,@RequestBody Revision request) { service.finish(user(auth),id,request.revision()); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
@Component
|
||||
class AppointmentMailTransport {
|
||||
private final JavaMailSenderImpl sender=new JavaMailSenderImpl();
|
||||
final String from;
|
||||
AppointmentMailTransport(@Value("${app.mail.host:}")String host,@Value("${app.mail.port:25}")int port,@Value("${app.mail.username:}")String username,@Value("${app.mail.password:}")String password,@Value("${app.mail.starttls:true}")boolean tls,@Value("${app.mail.ssl:false}")boolean ssl,@Value("${app.mail.from:}")String from){sender.setHost(host);sender.setPort(port);sender.setUsername(username);sender.setPassword(password);sender.setDefaultEncoding("UTF-8");this.from=from;var p=sender.getJavaMailProperties();p.setProperty("mail.smtp.auth",Boolean.toString(!username.isBlank()));p.setProperty("mail.smtp.starttls.enable",Boolean.toString(tls));p.setProperty("mail.smtp.ssl.enable",Boolean.toString(ssl));p.setProperty("mail.smtp.ssl.checkserveridentity","true");p.setProperty("mail.smtp.connectiontimeout","10000");p.setProperty("mail.smtp.timeout","10000");p.setProperty("mail.smtp.writetimeout","10000");}
|
||||
boolean configured(){return sender.getHost()!=null&&!sender.getHost().isBlank();}
|
||||
void send(String from,String replyTo,java.util.List<String> recipients,String subject,String body){var msg=new SimpleMailMessage();msg.setFrom(from);msg.setReplyTo(replyTo);msg.setBcc(recipients.toArray(String[]::new));msg.setSubject(subject);msg.setText(body);sender.send(msg);}
|
||||
void sendHtml(String from,String replyTo,String recipient,java.util.List<String> cc,java.util.List<String> bcc,String subject,String html){
|
||||
try {var mime=sender.createMimeMessage();var helper=new org.springframework.mail.javamail.MimeMessageHelper(mime,true,"UTF-8");helper.setFrom(from);helper.setReplyTo(replyTo);helper.setTo(recipient);if(!cc.isEmpty())helper.setCc(cc.toArray(String[]::new));if(!bcc.isEmpty())helper.setBcc(bcc.toArray(String[]::new));helper.setSubject(subject);
|
||||
var document=org.jsoup.Jsoup.parse(html);var embedded=new java.util.LinkedHashMap<String,String>();for(var img:document.select("img[src]")){String data=img.attr("src");if(data.matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+")){String cid="newsletter-image-"+embedded.size();embedded.put(cid,data);img.attr("src","cid:"+cid);}}helper.setText(document.text(),document.html());for(var item:embedded.entrySet()){String data=item.getValue();String type=data.substring(5,data.indexOf(';'));byte[] bytes=java.util.Base64.getDecoder().decode(data.substring(data.indexOf(',')+1));helper.addInline(item.getKey(),new org.springframework.core.io.ByteArrayResource(bytes),type);}sender.send(mime);
|
||||
}catch(jakarta.mail.MessagingException e){throw new org.springframework.mail.MailPreparationException("Newsletter konnte nicht vorbereitet werden.",e);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController
|
||||
@RequestMapping("/api/appointments")
|
||||
class AppointmentNotificationController {
|
||||
private final AppointmentNotifications service;
|
||||
AppointmentNotificationController(AppointmentNotifications service){this.service=service;}
|
||||
@PostMapping("/{id}/notifications/preview") Map<String,Object> preview(Authentication a,@PathVariable long id,@RequestBody AppointmentNotifications.Draft d){return service.preview(user(a),id,d);}
|
||||
@PostMapping("/notifications/{token}/send") Map<String,Object> send(Authentication a,@PathVariable String token){return service.send(user(a),token);}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
@Service
|
||||
class AppointmentNotifications {
|
||||
enum Kind {NEW,CHANGED,CANCELLED}
|
||||
record Draft(Kind kind,boolean participants,String external,String salutation,String greeting){}
|
||||
private final DatabaseService db;private final AppointmentService appointments;private final AppointmentMailTransport transport;
|
||||
private final Map<String,Pending> pending=new LinkedHashMap<>();
|
||||
private static class Pending {long user,hq,appointment;String revision,from,reply,subject,body;List<String> recipients;Instant expires;Map<String,Object> result;boolean sending;}
|
||||
AppointmentNotifications(DatabaseService db,AppointmentService appointments,AppointmentMailTransport transport){this.db=db;this.appointments=appointments;this.transport=transport;}
|
||||
static String address(String raw){if(raw==null||raw.isBlank()||raw.length()>254||raw.contains("\r")||raw.contains("\n"))throw bad("Ungültige E-Mail-Adresse.");try{var a=new InternetAddress(raw.strip(),true);a.validate();if(a.getPersonal()!=null||!a.getAddress().contains("@"))throw bad("Bitte reine E-Mail-Adressen ohne Anzeigenamen eingeben.");return a.getAddress();}catch(jakarta.mail.internet.AddressException e){throw bad("Ungültige E-Mail-Adresse.");}}
|
||||
Map<String,Object> preview(LegacyUser u,long appointment,Draft d){var row=appointments.appointment(u,appointment);AppointmentService.editable(u,row);if(d==null||d.kind()==null)throw bad("Benachrichtigungsart fehlt.");if(d.kind()==Kind.CANCELLED&&id(row,"usr_id")!=u.userId())u.require(3);if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert. SMTP_HOST in der .env ergänzen.");
|
||||
String external=d.external()==null?"":d.external().strip();if(external.length()>2000)throw bad("Zu viele externe Adressen.");var recipients=new TreeMap<String,String>();var missing=new ArrayList<String>();String actor=address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));
|
||||
if(d.participants()){recipients.put(actor.toLowerCase(Locale.ROOT),actor);var ids=GroupAdministration.memberships(text(row,"ap_participants"));if(!ids.isEmpty()){var args=new ArrayList<Object>(ids);args.add(u.headquartersId());var found=new HashSet<Long>();for(var person:db.queryForList("SELECT DISTINCT u.usr_id,u.usr_email,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+CrmScope.marks(ids.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray())){found.add(id(person,"usr_id"));try{String a=address(text(person,"usr_email"));recipients.put(a.toLowerCase(Locale.ROOT),a);}catch(org.springframework.web.server.ResponseStatusException e){missing.add(text(person,"label"));}}for(long id:ids)if(!found.contains(id))missing.add("Benutzer "+id+" (nicht verfügbar)");}}
|
||||
if(!external.isEmpty())for(String part:external.split("[,;]",-1)){String a=address(part);recipients.put(a.toLowerCase(Locale.ROOT),a);}if(recipients.isEmpty()||recipients.size()>100)throw bad("Bitte 1 bis 100 Empfänger auswählen.");
|
||||
String salutation=d.salutation()==null?"":d.salutation().strip(),greeting=d.greeting()==null?"":d.greeting().strip();if(salutation.length()>1000||greeting.length()>1000)throw bad("Anrede oder Grußformel ist zu lang.");String label=switch(d.kind()){case NEW->"Neuer Termin";case CHANGED->"Termin geändert";case CANCELLED->"Termin abgesagt";};if(dateTime(row,"ap_execdate")==null)throw bad("Der gespeicherte Termin hat keinen gültigen Beginn.");String start=dateTime(row,"ap_execdate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm"));var p=new Pending();p.user=u.userId();p.hq=u.headquartersId();p.appointment=appointment;p.revision=revision(AppointmentService.version(row));p.from=transport.from.isBlank()?actor:address(transport.from);p.reply=actor;p.subject=label+": "+start;p.body=salutation+"\n\n"+label+"\nBeginn: "+start+"\nEnde: "+(dateTime(row,"ap_enddate")==null?"offen":dateTime(row,"ap_enddate").format(DateTimeFormatter.ofPattern("dd.MM.uuuu HH:mm")))+"\n\n"+text(row,"ap_text")+"\n\n"+greeting;p.recipients=List.copyOf(recipients.values());p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->v.expires.isBefore(Instant.now())&&!v.sending);if(pending.size()>=1000)throw bad("Zu viele Versandvorschauen. Bitte später versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"recipients",p.recipients,"missing",missing,"subject",p.subject,"body",p.body);}
|
||||
Map<String,Object> send(LegacyUser u,String token){u.require(0);u.require(11);Pending p;synchronized(pending){p=pending.get(token);if(p==null||p.user!=u.userId()||p.hq!=u.headquartersId()||p.expires.isBefore(Instant.now()))throw bad("Versandvorschau abgelaufen. Bitte neu erstellen.");if(p.result!=null)return p.result;if(p.sending)throw bad("Versand läuft bereits. Bitte warten.");var row=appointments.appointment(u,p.appointment);AppointmentService.editable(u,row);unchanged(AppointmentService.version(row),p.revision);p.sending=true;}
|
||||
Map<String,Object> result;try{transport.send(p.from,p.reply,p.recipients,p.subject,p.body);result=fields("sent",true,"message","E-Mail vom SMTP-Server angenommen. Der Termin bleibt gespeichert.");}catch(org.springframework.mail.MailException e){result=fields("sent",false,"message","Der Versand konnte nicht vollständig bestätigt werden. Der Termin bleibt gespeichert. Bitte Zustellung prüfen, bevor Sie eine neue Nachricht senden.");}synchronized(pending){p.result=result;p.sending=false;}return result;}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.*;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Port of groupware/appointment.php. Notifications use the separate preview/send workflow. */
|
||||
@Service
|
||||
public class AppointmentService {
|
||||
static final String TABLE="`phoenix_group`.appointment";
|
||||
static final List<String> COLUMNS=List.of("ap_id","hq_id","usr_id","ap_type","ap_category_1","ap_category_2","ap_category_3","ap_category_4","ap_participants","ap_text","ap_execdate","ap_enddate","ap_confirmed","ap_modifytime","cs_id","csc_id","pt_id");
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
public AppointmentService(DatabaseService db,OrderEntryCatalog catalog) { this.db=db;this.catalog=catalog; }
|
||||
enum Visibility { OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC,INVITED,OTHER_PUBLIC }
|
||||
record Query(LocalDate from,LocalDate to,List<Long> headquarters,Long personId,Long customerId,Integer status,Integer kind,Integer monitoring,List<Visibility> visibility,String search) {}
|
||||
record Save(String revision,LocalDateTime startsAt,LocalDateTime endsAt,String text,int status,int visibility,int kind,int monitoring,Long customerId,List<Long> participants) {}
|
||||
record Resubmission(String revision,LocalDateTime startsAt,LocalDateTime endsAt) {}
|
||||
record Sql(String where,Object[] args) {}
|
||||
private void access(LegacyUser user) { user.require(0);user.require(11); }
|
||||
private static String marks(int size) { return String.join(",",Collections.nCopies(size,"?")); }
|
||||
private static String participant(long userId) { return "%,"+userId+",%"; }
|
||||
private List<Map<String,Object>> headquarters(LegacyUser user) {
|
||||
return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(user.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",user.hasRight(10)?new Object[]{user.headquartersId()}:new Object[]{user.headquartersId(),user.headquartersId()});
|
||||
}
|
||||
private List<Long> scope(LegacyUser user,List<Long> requested) {
|
||||
if(requested==null||requested.isEmpty()||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine Niederlassung wählen.");
|
||||
var ids=requested.stream().distinct().sorted().toList();
|
||||
if(ids.size()==1&&ids.getFirst()==user.headquartersId())return ids;
|
||||
user.require(10);
|
||||
var allowed=headquarters(user).stream().map(r->id(r,"id")).toList();
|
||||
if(!allowed.containsAll(ids))throw new AccessDeniedException("Niederlassung ist für diesen Mandanten nicht freigegeben.");
|
||||
return ids;
|
||||
}
|
||||
private List<Long> allowedHeadquarters(LegacyUser user) {
|
||||
var ids=headquarters(user).stream().map(r->id(r,"id")).toList();
|
||||
if(ids.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");return ids;
|
||||
}
|
||||
private List<Map<String,Object>> employees(List<Long> hqs) {
|
||||
var rows=db.queryForList("SELECT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label,u.hq_id AS headquartersId,h.hq_mnemonic AS headquarters,e.emp_group AS groups FROM user u JOIN employee e ON e.usr_id=u.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_type=1 AND u.hq_id IN ("+marks(hqs.size())+") ORDER BY u.usr_name,u.usr_firstname,u.usr_id LIMIT 5001",hqs.toArray());
|
||||
if(rows.size()>5000)throw bad("Zu viele Mitarbeiter. Bitte die Niederlassungsauswahl eingrenzen.");
|
||||
return rows;
|
||||
}
|
||||
private Map<String,List<Map<String,Object>>> categories() {
|
||||
var result=new LinkedHashMap<String,List<Map<String,Object>>>();
|
||||
var labels=new HashMap<String,String>();
|
||||
for(var p:db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key LIKE 'MASK_AP_CAT_%'"))labels.put(text(p,"par_key"),text(p,"par_value"));
|
||||
for(int category=1;category<=4;category++) {
|
||||
var options=new ArrayList<Map<String,Object>>();
|
||||
for(var r:db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type=? GROUP BY mt_sort ORDER BY mt_sort","appointment_category_"+category)) {
|
||||
long id=id(r,"id");String override=labels.get("MASK_AP_CAT_"+category+"_"+id);
|
||||
options.add(fields("id",id,"label",override==null||override.isBlank()?text(r,"label"):override));
|
||||
}
|
||||
if(category==4&&options.stream().noneMatch(o->id(o,"id")==2))options.add(fields("id",2L,"label",labels.getOrDefault("MASK_AP_CAT_4_9","Erledigt")));
|
||||
result.put("category"+category,options);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
public Map<String,Object> options(LegacyUser user) {
|
||||
access(user);var hqs=headquarters(user);if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");
|
||||
var people=employees(hqs.stream().map(r->id(r,"id")).toList());
|
||||
var groups=db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? ORDER BY grp_name",mandator(db,user));
|
||||
var groupViews=new ArrayList<Map<String,Object>>();
|
||||
for(var g:groups) {
|
||||
var members=people.stream().filter(p->GroupAdministration.memberships(text(p,"groups")).contains(id(g,"id"))).map(p->id(p,"id")).toList();
|
||||
if(!members.isEmpty())groupViews.add(fields("id",id(g,"id"),"label",text(g,"label"),"members",members));
|
||||
}
|
||||
return fields("headquarters",hqs,"currentHeadquarters",user.headquartersId(),"currentUser",user.userId(),"now",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0),"categories",categories(),"people",people,"groups",groupViews,
|
||||
"defaultMonitoring",catalog.enabled(0,"MASK_AP_CATEGORY_4_PRESET")?1:0,"miscType",catalog.parameter(0,"MASK_AP_CATEGORY_3_MISC_ITEM"),"canReadReports",user.hasRight(16));
|
||||
}
|
||||
public List<Map<String,Object>> customers(LegacyUser user,String q) {
|
||||
access(user);if(q==null||q.length()>100)throw bad("Suchtext zu lang.");var hqs=allowedHeadquarters(user);
|
||||
var args=new ArrayList<Object>(hqs);args.add("%"+q.strip()+"%");
|
||||
return db.queryForList("SELECT c.cs_id AS id,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer c LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE c.hq_id IN ("+marks(hqs.size())+") AND CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ? ORDER BY label,id LIMIT 50",args.toArray());
|
||||
}
|
||||
private void customer(LegacyUser user,long customerId) {
|
||||
var hqs=allowedHeadquarters(user);var args=new ArrayList<Object>(List.of(customerId));args.addAll(hqs);
|
||||
one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id IN ("+marks(hqs.size())+")",args.toArray()));
|
||||
}
|
||||
static void validate(Query q,boolean statistics) {
|
||||
if(q==null)throw bad("Terminfilter fehlen.");range(q.from(),q.to());
|
||||
if(q.from().getYear()<1000||q.to().getYear()>9998||!statistics&&ChronoUnit.DAYS.between(q.from(),q.to())>365)throw bad("Kalenderabfragen dürfen höchstens ein Jahr umfassen.");
|
||||
if(q.visibility()==null||q.visibility().isEmpty()||q.visibility().stream().anyMatch(Objects::isNull)||q.visibility().size()>5)throw bad("Bitte mindestens eine Sichtbarkeitsgruppe wählen.");
|
||||
if(q.search()==null||q.search().length()>200)throw bad("Suchtext darf höchstens 200 Zeichen enthalten.");
|
||||
for(Long id:Arrays.asList(q.personId(),q.customerId()))if(id!=null&&id<=0)throw bad("Ungültiger Personen-/Kundenfilter.");
|
||||
for(Integer code:Arrays.asList(q.status(),q.kind(),q.monitoring()))if(code!=null&&(code<0||code>127))throw bad("Ungültiger Kategoriefilter.");
|
||||
}
|
||||
/** Visibility is authorization, not merely a UI search option. No master bypass for private appointments. */
|
||||
private static String visible(LegacyUser user,List<Object> args) {
|
||||
args.add(user.userId());args.add(participant(user.userId()));
|
||||
return "(a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))";
|
||||
}
|
||||
private Sql selection(LegacyUser user,Query q,boolean statistics) {
|
||||
validate(q,statistics);var hqs=scope(user,q.headquarters());
|
||||
var args=new ArrayList<Object>(hqs);
|
||||
String where="a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args)+" AND a.ap_execdate<? AND (a.ap_execdate>=? OR a.ap_enddate>=?)";
|
||||
args.add(q.to().plusDays(1).atStartOfDay());args.add(q.from().atStartOfDay());args.add(q.from().atStartOfDay());
|
||||
if(q.personId()!=null){where+=" AND (a.usr_id=? OR a.ap_participants LIKE ?)";args.add(q.personId());args.add(participant(q.personId()));}
|
||||
if(q.customerId()!=null){customer(user,q.customerId());where+=" AND a.cs_id=?";args.add(q.customerId());}
|
||||
Integer[] codes={q.status(),q.kind(),q.monitoring()};int[] categories={1,3,4};
|
||||
for(int i=0;i<codes.length;i++)if(codes[i]!=null){where+=" AND a.ap_category_"+categories[i]+"=?";args.add(codes[i]);}
|
||||
if(!q.search().isBlank()){where+=" AND a.ap_text LIKE ?";args.add("%"+q.search().strip()+"%");}
|
||||
if(!new HashSet<>(q.visibility()).containsAll(List.of(Visibility.values()))) {
|
||||
var clauses=new ArrayList<String>();
|
||||
for(var v:new LinkedHashSet<>(q.visibility()))switch(v) {
|
||||
case OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC -> { clauses.add("(a.usr_id=? AND a.ap_category_2=?)");args.add(user.userId());args.add(v==Visibility.OWN_PRIVATE?2:v==Visibility.OWN_PARTICIPANTS?1:3); }
|
||||
case INVITED -> { clauses.add("(a.usr_id<>? AND a.ap_participants LIKE ?)");args.add(user.userId());args.add(participant(user.userId())); }
|
||||
case OTHER_PUBLIC -> { clauses.add("(a.usr_id<>? AND a.ap_category_2=3 AND (a.ap_participants IS NULL OR a.ap_participants NOT LIKE ?))");args.add(user.userId());args.add(participant(user.userId())); }
|
||||
}
|
||||
where+=" AND ("+String.join(" OR ",clauses)+")";
|
||||
}
|
||||
return new Sql(where,args.toArray());
|
||||
}
|
||||
private static String selectColumns() { return String.join(",",COLUMNS.stream().map(c->"a."+c).toList()); }
|
||||
private static String joins() { return " FROM "+TABLE+" a LEFT JOIN user u ON u.usr_id=a.usr_id LEFT JOIN headquarters h ON h.hq_id=a.hq_id LEFT JOIN customer c ON c.cs_id=a.cs_id LEFT JOIN company co ON co.cmp_id=c.cmp_id"; }
|
||||
public Map<String,Object> list(LegacyUser user,Query query) {
|
||||
access(user);return db.readTransaction(()->{
|
||||
var sql=selection(user,query,false);
|
||||
var rows=db.queryForList("SELECT "+selectColumns()+",CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS customer_label"+joins()+" WHERE "+sql.where()+" ORDER BY a.ap_execdate,a.ap_id LIMIT 5001",sql.args());
|
||||
if(rows.size()>5000)throw bad("Mehr als 5000 Termine. Bitte Zeitraum oder Filter eingrenzen.");
|
||||
var names=participantNames(user,rows);
|
||||
return fields("items",rows.stream().map(r->view(user,r,names)).toList(),"total",rows.size(),"from",query.from(),"to",query.to());
|
||||
});
|
||||
}
|
||||
private Map<Long,String> participantNames(LegacyUser user,List<Map<String,Object>> rows) {
|
||||
var ids=new LinkedHashSet<Long>();for(var row:rows)ids.addAll(GroupAdministration.memberships(text(row,"ap_participants")));
|
||||
var names=new HashMap<Long,String>();if(ids.isEmpty())return names;
|
||||
if(ids.size()>10000)throw bad("Zu viele Teilnehmer. Bitte den Zeitraum eingrenzen.");
|
||||
var all=new ArrayList<>(ids);
|
||||
for(int i=0;i<all.size();i+=500) {
|
||||
var batch=all.subList(i,Math.min(i+500,all.size()));var args=new ArrayList<Object>(batch);args.add(user.headquartersId());
|
||||
for(var r:db.queryForList("SELECT DISTINCT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+marks(batch.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray()))names.put(id(r,"id"),text(r,"label"));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
static Map<String,Object> version(Map<String,Object> row) { var result=new LinkedHashMap<String,Object>();for(String key:COLUMNS)result.put(key,value(row,key));return result; }
|
||||
private static boolean canEdit(LegacyUser user,Map<String,Object> row) { return id(row,"usr_id")==user.userId()||user.hasRight(3)||GroupAdministration.memberships(text(row,"ap_participants")).contains(user.userId()); }
|
||||
private Map<String,Object> view(LegacyUser user,Map<String,Object> row,Map<Long,String> names) {
|
||||
var people=GroupAdministration.memberships(text(row,"ap_participants"));var confirmed=GroupAdministration.memberships(text(row,"ap_confirmed"));
|
||||
return fields("id",id(row,"ap_id"),"headquartersId",id(row,"hq_id"),"headquarters",text(row,"headquarters"),"authorId",id(row,"usr_id"),"author",text(row,"author"),"text",text(row,"ap_text"),"startsAt",dateTime(row,"ap_execdate"),"endsAt",dateTime(row,"ap_enddate"),
|
||||
"status",id(row,"ap_category_1"),"visibility",id(row,"ap_category_2"),"kind",id(row,"ap_category_3"),"monitoring",id(row,"ap_category_4"),"customerId",positiveId(row,"cs_id"),"customerLabel",text(row,"customer_label"),
|
||||
"participants",people.stream().map(id->fields("id",id,"label",names.getOrDefault(id,"Benutzer "+id),"confirmed",confirmed.contains(id))).toList(),"revision",revision(version(row)),"canEdit",canEdit(user,row),"canDelete",id(row,"usr_id")==user.userId()||user.hasRight(3),"canConfirm",people.contains(user.userId())&&!confirmed.contains(user.userId()),"canFinish",canEdit(user,row)&&id(row,"ap_category_4")==1,
|
||||
"hasLegacyRelation",id(row,"csc_id")>0||id(row,"pt_id")>0);
|
||||
}
|
||||
public Map<String,Object> statistics(LegacyUser user,Query query) {
|
||||
access(user);return db.readTransaction(()->{
|
||||
var sql=selection(user,query,true);
|
||||
var rows=db.queryForList("SELECT a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4,COUNT(*) AS total FROM "+TABLE+" a WHERE "+sql.where()+" GROUP BY a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4 LIMIT 5001",sql.args());
|
||||
if(rows.size()>5000)throw bad("Zu viele Statistikgruppen. Bitte Filter eingrenzen.");
|
||||
var metadata=categories();var categories=new ArrayList<Map<String,Object>>();
|
||||
for(int n=1;n<=4;n++) {
|
||||
var counts=new TreeMap<Long,Long>();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),Long::sum);
|
||||
var labels=new HashMap<Long,String>();for(var option:metadata.get("category"+n)){long id=id(option,"id");labels.put(id,text(option,"label"));counts.putIfAbsent(id,0L);}
|
||||
categories.add(fields("category",n,"items",counts.entrySet().stream().map(e->fields("id",e.getKey(),"label",labels.getOrDefault(e.getKey(),"Unbekannt ("+e.getKey()+")"),"total",e.getValue())).toList()));
|
||||
}
|
||||
return fields("total",rows.stream().mapToLong(r->id(r,"total")).sum(),"categories",categories,"from",query.from(),"to",query.to());
|
||||
});
|
||||
}
|
||||
Map<String,Object> appointment(LegacyUser user,long appointmentId) {
|
||||
access(user);var hqs=allowedHeadquarters(user);var args=new ArrayList<Object>(List.of(appointmentId));args.addAll(hqs);
|
||||
String where="a.ap_id=? AND a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args);
|
||||
return one(db.queryForList("SELECT "+selectColumns()+" FROM "+TABLE+" a WHERE "+where,args.toArray()));
|
||||
}
|
||||
static void editable(LegacyUser user,Map<String,Object> old) { if(!canEdit(user,old))throw new AccessDeniedException("Nur Ersteller, Teilnehmer oder Mitarbeiterverwaltung dürfen diesen Termin ändern."); }
|
||||
static void interval(LocalDateTime starts,LocalDateTime ends) {
|
||||
if(starts==null||ends==null||starts.getYear()<1000||ends.getYear()>9998||ends.isBefore(starts)||starts.plusYears(10).isBefore(ends))throw bad("Bitte einen gültigen Beginn und ein Ende ab Beginn angeben (höchstens zehn Jahre).");
|
||||
if(starts.getSecond()!=0||starts.getNano()!=0||ends.getSecond()!=0||ends.getNano()!=0)throw bad("Termine bitte minutengenau eingeben.");
|
||||
}
|
||||
private Map<String,Object> values(LegacyUser user,Save request,Map<String,Object> old) {
|
||||
if(request==null)throw bad("Termin fehlt.");interval(request.startsAt(),request.endsAt());
|
||||
String text=required(request.text(),65535,"Termintext");if(text.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Termintext ist zu lang (65535 UTF-8-Bytes).");
|
||||
int[] values={request.status(),request.visibility(),request.kind(),request.monitoring()};var metadata=categories();
|
||||
for(int i=0;i<values.length;i++){int code=values[i];if(code<0||code>127||metadata.get("category"+(i+1)).stream().noneMatch(v->id(v,"id")==code))throw bad("Unbekannte Terminkategorie.");}
|
||||
if(!Set.of(1,2).contains(request.status())||!Set.of(1,2,3).contains(request.visibility())||!Set.of(0,1,2).contains(request.monitoring()))throw bad("Ungültiger Status, Sichtbarkeit oder Kontrolle.");
|
||||
if(request.customerId()!=null) {
|
||||
if(request.customerId()<=0)throw bad("Ungültige Kundenzuordnung.");
|
||||
if(old==null||request.customerId()!=id(old,"cs_id"))customer(user,request.customerId());
|
||||
}
|
||||
if(old!=null&&(id(old,"csc_id")>0||id(old,"pt_id")>0)&&!Objects.equals(request.customerId(),positiveId(old,"cs_id")))throw bad("Die vorhandene Kostenstellen-/Kontaktverknüpfung verhindert eine andere Kundenzuordnung.");
|
||||
if(request.participants()==null||request.participants().size()>500||request.participants().stream().anyMatch(id->id==null||id<=0))throw bad("Bitte höchstens 500 gültige Teilnehmer auswählen.");
|
||||
var people=new TreeSet<>(request.participants());var allowed=new HashSet<Long>();for(var p:employees(allowedHeadquarters(user)))allowed.add(id(p,"id"));
|
||||
if(old!=null)allowed.addAll(GroupAdministration.memberships(text(old,"ap_participants")));
|
||||
if(!allowed.containsAll(people))throw new AccessDeniedException("Ein Teilnehmer ist nicht für die freigegebenen Niederlassungen verfügbar.");
|
||||
var confirmed=old==null?new TreeSet<Long>():new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed")));
|
||||
if(old!=null&&(!request.startsAt().equals(dateTime(old,"ap_execdate"))||!request.endsAt().equals(dateTime(old,"ap_enddate"))))confirmed.clear();
|
||||
confirmed.retainAll(people);
|
||||
return fields("ap_text",text,"ap_execdate",request.startsAt(),"ap_enddate",request.endsAt(),"ap_category_1",request.status(),"ap_category_2",request.visibility(),"ap_category_3",request.kind(),"ap_category_4",request.monitoring(),"cs_id",request.customerId(),"ap_participants",ids(people),"ap_confirmed",ids(confirmed),"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));
|
||||
}
|
||||
private static Long positiveId(Map<String,Object> row,String key) { long value=id(row,key);return value>0?value:null; }
|
||||
static String ids(Collection<Long> ids) { return ids.isEmpty()?"":","+String.join(",",ids.stream().map(String::valueOf).toList())+","; }
|
||||
public Map<String,Object> create(LegacyUser user,Save request) {
|
||||
access(user);var fields=values(user,request,null);
|
||||
if(request.monitoring()==2)throw bad("Neue Termine bitte zunächst ohne Kontrolle oder mit aktiver Überwachung anlegen.");
|
||||
fields.putAll(fields("usr_id",user.userId(),"hq_id",user.headquartersId(),"ap_type",0,"csc_id",null,"pt_id",null));
|
||||
long id=db.writeTransaction(()->db.insert(TABLE,fields));return fields("id",id);
|
||||
}
|
||||
public Map<String,Object> save(LegacyUser user,long appointmentId,Save request) {
|
||||
access(user);return db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);editable(user,old);if(request==null)throw bad("Termin fehlt.");unchanged(version(old),request.revision());
|
||||
var values=values(user,request,old);
|
||||
if(id(old,"ap_category_4")!=2&&request.monitoring()==2) {
|
||||
// Check the resulting customer/status too; changing the assignment must not bypass the report rule.
|
||||
var completed=new LinkedHashMap<>(old);completed.putAll(values);requireReport(user,completed);
|
||||
}
|
||||
update(old,values);return fields("id",appointmentId);
|
||||
});
|
||||
}
|
||||
public void remove(LegacyUser user,long appointmentId,String revision) {
|
||||
access(user);db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);if(id(old,"usr_id")!=user.userId())user.require(3);unchanged(version(old),revision);
|
||||
changed(db.update("DELETE FROM "+TABLE+" WHERE "+cas(),casArgs(old).toArray()));return null;
|
||||
});
|
||||
}
|
||||
public Map<String,Object> resubmit(LegacyUser user,long appointmentId,Resubmission request) {
|
||||
access(user);if(request==null)throw bad("Wiedervorlage fehlt.");interval(request.startsAt(),request.endsAt());
|
||||
var old=appointment(user,appointmentId);unchanged(version(old),request.revision());
|
||||
// A resubmission creates a new appointment. Its source and source confirmations stay untouched.
|
||||
var values=new LinkedHashMap<>(version(old));values.remove("ap_id");values.put("usr_id",user.userId());values.put("hq_id",user.headquartersId());
|
||||
values.put("ap_execdate",request.startsAt());values.put("ap_enddate",request.endsAt());values.put("ap_confirmed","");values.put("ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0));
|
||||
if(id(old,"ap_category_4")==2)values.put("ap_category_4",1);
|
||||
long id=db.writeTransaction(()->db.insert(TABLE,values));return fields("id",id);
|
||||
}
|
||||
public void confirm(LegacyUser user,long appointmentId,String revision) {
|
||||
access(user);db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);
|
||||
if(!GroupAdministration.memberships(text(old,"ap_participants")).contains(user.userId()))throw new AccessDeniedException("Nur eigene Teilnahme kann bestätigt werden.");
|
||||
unchanged(version(old),revision);var confirmed=new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed")));confirmed.add(user.userId());
|
||||
update(old,fields("ap_confirmed",ids(confirmed)));return null;
|
||||
});
|
||||
}
|
||||
public void finish(LegacyUser user,long appointmentId,String revision) {
|
||||
access(user);db.writeTransaction(()->{
|
||||
var old=appointment(user,appointmentId);editable(user,old);unchanged(version(old),revision);
|
||||
if(id(old,"ap_category_4")!=1)throw bad("Nur ein überwachter Termin kann abgeschlossen werden.");requireReport(user,old);
|
||||
update(old,fields("ap_category_4",2,"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0)));return null;
|
||||
});
|
||||
}
|
||||
private void requireReport(LegacyUser user,Map<String,Object> appointment) {
|
||||
long customerId=id(appointment,"cs_id");
|
||||
if(customerId>0&&id(appointment,"ap_category_1")==1&&catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")) {
|
||||
var today=LocalDate.now(ZoneId.of("Europe/Berlin"));var args=new ArrayList<Object>(List.of(customerId,id(appointment,"hq_id"),today.atStartOfDay(),today.plusDays(1).atStartOfDay()));
|
||||
String sql="SELECT rp_id FROM `phoenix_group`.report_process WHERE rp_objtype='cs' AND rp_objid=? AND hq_id=? AND rp_createtime>=? AND rp_createtime<?";
|
||||
if(!user.hasRight(10)){sql+=" AND (rp_confidential=0 OR usr_id=?)";args.add(user.userId());}
|
||||
if(db.queryForList(sql+" LIMIT 1",args.toArray()).isEmpty())throw bad("Bitte zuerst einen Kundenbericht für den heutigen Tag anlegen.");
|
||||
}
|
||||
}
|
||||
private void update(Map<String,Object> old,Map<String,Object> values) {
|
||||
var args=new ArrayList<>(values.values());args.addAll(casArgs(old));
|
||||
changed(db.update("UPDATE "+TABLE+" SET "+String.join(",",values.keySet().stream().map(c->c+"=?").toList())+" WHERE "+cas(),args.toArray()));
|
||||
}
|
||||
// MyISAM does not support transactional row locks. Include all source values in each mutation.
|
||||
private static String cas() { return String.join(" AND ",COLUMNS.stream().map(c->Set.of("ap_text","ap_participants","ap_confirmed").contains(c)?"BINARY "+c+" <=> BINARY ?":c+" <=> ?").toList()); }
|
||||
private static List<Object> casArgs(Map<String,Object> old) { var args=new ArrayList<Object>();for(String c:COLUMNS)args.add(value(old,c));return args; }
|
||||
private static void changed(int rows) { if(rows!=1)throw new ResponseStatusException(HttpStatus.CONFLICT,"Der Termin wurde zwischenzeitlich geändert oder gelöscht. Bitte neu laden."); }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
|
||||
/** Native equivalents of invoice.php, jb_inv_assoc.php and the customer/courier statement. */
|
||||
@Service
|
||||
public class BillingService {
|
||||
final DatabaseService db;final BusinessSupport support;
|
||||
public BillingService(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
public record Query(LocalDate from,LocalDate to,String mode,Long customer,Long center,boolean descendants,Long courier,String invoice,boolean unassigned){}
|
||||
public record Assign(String requestId,Query query,String revision,List<Long> orders,String number,LocalDate date){}
|
||||
record Assignment(long id,String revision){}
|
||||
record Operation(long user,long hq,String number,String date,List<Assignment> items,String hash){}
|
||||
static BigDecimal nz(BigDecimal v){return v==null?BigDecimal.ZERO:v;}
|
||||
static BigDecimal money(BigDecimal v){return v.setScale(2,RoundingMode.HALF_UP);}
|
||||
static BigDecimal net(Map<String,Object> p){return money(nz(decimal(p,"trs_price")).multiply(BigDecimal.valueOf(100).subtract(nz(decimal(p,"trs_discount")))).movePointLeft(2));}
|
||||
void check(LegacyUser u,Query q){u.require(5);if(q==null)throw bad("Abrechnungsfilter fehlen.");range(q.from(),q.to());if(q.from().plusYears(1).isBefore(q.to()))throw bad("Bitte einen Zeitraum von höchstens einem Jahr wählen.");if(q.mode()==null||!Set.of("CUSTOMER","COURIER").contains(q.mode()))throw bad("Ungültiger Abrechnungsbereich.");}
|
||||
public Map<String,Object> options(LegacyUser u){u.require(5);return fields("customers",db.queryForList("SELECT cs.cs_id AS id,CONCAT(cs.cs_eid,' · ',c.cmp_comp) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"couriers",db.queryForList("SELECT cr.cr_id AS id,CONCAT(cr.cr_eid,' · ',c.cmp_comp) AS label FROM courier cr JOIN company c ON c.cmp_id=cr.cmp_id WHERE cr.hq_id=? ORDER BY c.cmp_comp",u.headquartersId()),"canAssign",u.hasRight(7));}
|
||||
public List<Map<String,Object>> centers(LegacyUser u,long customer){u.require(5);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));return db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name",customer);}
|
||||
public Map<String,Object> query(LegacyUser u,Query q){check(u,q);return db.readTransaction(()->statement(u,q));}
|
||||
Map<String,Object> statement(LegacyUser u,Query q){
|
||||
Set<Long> centers=new HashSet<>();if(q.center()!=null){var cs=one(db.queryForList("SELECT c.csc_id,c.cs_id FROM costcenter c JOIN customer s ON s.cs_id=c.cs_id WHERE c.csc_id=? AND s.hq_id=?",q.center(),u.headquartersId()));if(q.customer()!=null&&q.customer()!=id(cs,"cs_id"))throw bad("Die Kostenstelle gehört zu einem anderen Kunden.");centers.add(q.center());if(q.descendants()){var all=db.queryForList("SELECT csc_id,csc_pre_id FROM costcenter WHERE cs_id=?",id(cs,"cs_id"));boolean added;do{added=false;for(var r:all)if(centers.contains(id(r,"csc_pre_id")))added|=centers.add(id(r,"csc_id"));}while(added);}}
|
||||
String sql="SELECT j.*,i.inv_no,i.inv_datetime,c.cs_id,c.csc_name,cs.cs_eid,cmp.cmp_comp FROM job j LEFT JOIN invoice i ON i.jb_id=j.jb_id LEFT JOIN costcenter c ON c.csc_id=j.csc_id_payer LEFT JOIN customer cs ON cs.cs_id=c.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE j.hq_id=? AND j.jb_status=2 AND COALESCE(j.jb_offer,0)=0 AND j.jb_finishtime>=? AND j.jb_finishtime<?";
|
||||
var args=new ArrayList<Object>(List.of(u.headquartersId(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay()));
|
||||
if(q.mode().equals("CUSTOMER"))sql+=" AND j.jb_payment<>2 AND j.csc_id_payer<>-1";
|
||||
if(q.customer()!=null){sql+=" AND (c.cs_id=? OR EXISTS (SELECT 1 FROM tourservice t JOIN costcenter tc ON tc.csc_id=t.csc_id WHERE t.jb_id=j.jb_id AND tc.cs_id=?))";args.add(q.customer());args.add(q.customer());}
|
||||
if(q.courier()!=null){sql+=" AND j.cr_id=?";args.add(q.courier());}
|
||||
if(q.invoice()!=null&&!q.invoice().isBlank()){sql+=" AND i.inv_no=?";args.add(q.invoice().strip());}
|
||||
if(q.unassigned())sql+=" AND i.jb_id IS NULL";
|
||||
var jobs=db.queryForList(sql+" ORDER BY j.jb_finishtime,j.jb_id LIMIT 2001",args.toArray());if(jobs.size()>2000)throw bad("Mehr als 2.000 Aufträge gefunden. Bitte den Zeitraum oder den Kundenfilter eingrenzen.");
|
||||
var rows=new ArrayList<Map<String,Object>>();var revisionData=new ArrayList<Object>();revisionData.add(jobs);
|
||||
for(var j:jobs){long job=id(j,"jb_id");var prices=db.queryForList("SELECT t.*,c.cs_id,c.csc_name,s.cs_eid,cmp.cmp_comp FROM tourservice t LEFT JOIN costcenter c ON c.csc_id=t.csc_id LEFT JOIN customer s ON s.cs_id=c.cs_id LEFT JOIN company cmp ON cmp.cmp_id=s.cmp_id WHERE t.jb_id=? ORDER BY t.csc_id,t.tr_sort,t.srv_id,t.srvt_id",job);revisionData.add(prices);
|
||||
var tollRows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job);revisionData.add(tollRows);if(tollRows.size()>1)throw bad("Auftrag #"+job+": Maut ist mehrfach hinterlegt.");BigDecimal toll=tollRows.isEmpty()?BigDecimal.ZERO:new BigDecimal(text(tollRows.getFirst(),"gdc_content"));
|
||||
if(q.mode().equals("COURIER")){BigDecimal amount=courierNet(j,prices);rows.add(line(j,id(j,"csc_id_payer"),text(j,"csc_name"),amount,toll,text(j,"jb_invtext_cr")));continue;}
|
||||
var byCenter=new TreeMap<Long,List<Map<String,Object>>>();for(var p:prices)byCenter.computeIfAbsent(id(p,"csc_id"),k->new ArrayList<>()).add(p);
|
||||
if(byCenter.isEmpty())byCenter.put(id(j,"csc_id_payer"),List.of());int index=0;for(var e:byCenter.entrySet()){BigDecimal share=OrderEntryAccounting.portion(toll,byCenter.size(),index++);if(!centers.isEmpty()&&!centers.contains(e.getKey()))continue;
|
||||
var context=e.getValue().isEmpty()?j:e.getValue().getFirst();if(q.customer()!=null&&id(context,"cs_id")!=q.customer())continue;
|
||||
BigDecimal amount=e.getValue().isEmpty()?nz(decimal(j,"jb_totalprice")):e.getValue().stream().map(BillingService::net).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
var line=line(j,e.getKey(),text(context,"csc_name"),amount,share,text(j,"jb_invtext"));line.put("customer",text(context,"cmp_comp"));line.put("customerId",id(context,"cs_id"));line.put("components",e.getValue().stream().map(p->fields("name",text(p,"trs_srv_name")+" "+text(p,"trs_srvt_name"),"net",net(p))).toList());rows.add(line);
|
||||
}
|
||||
}
|
||||
var terms=new HashMap<Long,Map<String,Object>>();
|
||||
for(var row:rows){var term=terms.computeIfAbsent(id(row,"id"),key->OrderPaymentTerms.read(db,key));row.put("paymentTerm",text(term,"label"));row.put("paymentTermCode",term.getOrDefault("value",text(term,"label")));row.put("paymentTermSource",text(term,"source"));}
|
||||
revisionData.add(terms);
|
||||
BigDecimal net=rows.stream().map(r->(BigDecimal)r.get("net")).reduce(BigDecimal.ZERO,BigDecimal::add),tax=rows.stream().map(r->(BigDecimal)r.get("tax")).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
var pending=db.queryForList("SELECT gdc_gen_fieldname AS token FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",u.headquartersId());
|
||||
return fields("rows",rows,"net",money(net),"tax",money(tax),"gross",money(net.add(tax)),"revision",revision(revisionData),"pending",pending);
|
||||
}
|
||||
BigDecimal courierNet(Map<String,Object> job,List<Map<String,Object>> prices){
|
||||
var catalog=new OrderEntryCatalog(db);long hq=id(job,"hq_id");var vehicle=db.queryForList("SELECT crvh_prov FROM couriervehicle WHERE crvh_sid=?",text(job,"cr_sid"));BigDecimal commission=vehicle.isEmpty()?BigDecimal.ZERO:nz(decimal(one(vehicle),"crvh_prov"));
|
||||
BigDecimal stored=nz(decimal(job,hq>100&&hq<=1000?"jb_cr_subprice":"jb_cr_price"));if(hq>1000)return money(stored.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2));
|
||||
boolean mode=catalog.parameter(hq,"MASK_CR_PRICE_MODE").equals("1");String from=catalog.parameter(hq,"MASK_CR_PRICE_MODE_DATE");LocalDateTime at=dateTime(job,"jb_ordertime");boolean after=false;if(mode&&at!=null&&!from.isBlank()){try{after=at.isAfter(LocalDate.parse(from.substring(0,10)).atStartOfDay());}catch(RuntimeException ignored){throw bad("MASK_CR_PRICE_MODE_DATE ist ungültig.");}}
|
||||
if(stored.signum()!=0&&(hq>100||!mode)||mode&&after)return stored;
|
||||
boolean subtract=catalog.parameter(hq,"INV_JB_CR_PRICE").equals("1");return prices.stream().map(p->{BigDecimal amount=net(p);return subtract?money(amount.multiply(BigDecimal.valueOf(100).subtract(commission)).movePointLeft(2)):amount;}).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
}
|
||||
Map<String,Object> line(Map<String,Object> j,long center,String centerName,BigDecimal amount,BigDecimal toll,String description){BigDecimal net=money(amount.add(toll)),rate=nz(decimal(j,"jb_sales_tax_rate")),tax=money(net.multiply(rate).movePointLeft(2));return fields("id",id(j,"jb_id"),"center",center,"centerName",centerName,"customer",text(j,"cmp_comp"),"customerId",id(j,"cs_id"),"courier",text(j,"cr_sid"),"finished",dateTime(j,"jb_finishtime"),"ordered",dateTime(j,"jb_ordertime"),"booked",dateTime(j,"jb_booktime"),"reference",text(j,"jb_commission_no"),"description",description,"net",net,"toll",money(toll),"taxRate",rate,"tax",tax,"gross",net.add(tax),"storno",id(j,"jb_storno"),"incomplete",id(j,"jb_incomplete")==1,"exported",OrderOperations.exported(j),"invoice",text(j,"inv_no"),"invoiceDate",dateTime(j,"inv_datetime"));}
|
||||
public Map<String,Object> assign(LegacyUser u,Assign a){u.require(5);u.require(7);if(a==null)throw bad("Rechnungsangaben fehlen.");check(u,a.query());token(a.requestId());String number=required(a.number(),30,"Rechnungsnummer");if(a.date()==null||a.orders()==null||a.orders().isEmpty()||a.orders().size()>2000||new HashSet<>(a.orders()).size()!=a.orders().size())throw bad("Bitte Rechnungsdatum und eindeutige Aufträge auswählen.");if(!a.query().mode().equals("CUSTOMER")||a.query().customer()==null)throw bad("Rechnungszuordnungen benötigen genau einen ausgewählten Kunden.");
|
||||
support.transactional("headquarters","genericdatacontainer","job","tourservice");db.writeTransaction(()->{lockHeadquarters(db,u);var existing=operation(a.requestId());String hash=revision(List.of(u.userId(),u.headquartersId(),a));if(existing!=null){if(!decode(existing).hash().equals(hash))throw bad("Die Vorgangskennung wurde bereits für andere Daten verwendet.");return null;}
|
||||
var current=statement(u,a.query());if(!current.get("revision").equals(a.revision()))throw bad("Abrechnungsdaten wurden geändert. Bitte neu laden.");
|
||||
var lines=(List<Map<String,Object>>)current.get("rows");Set<Long> eligible=new HashSet<>();for(var row:lines)if(!(Boolean)row.get("incomplete")&&!(Boolean)row.get("exported")&&text(row,"invoice").isBlank())eligible.add(id(row,"id"));
|
||||
var items=new ArrayList<Assignment>();for(long job:a.orders()){if(!eligible.contains(job))throw bad("Auftrag #"+job+" ist unfertig, bereits zugeordnet, exportiert oder gehört nicht zur Auswahl.");one(db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",job,u.headquartersId()));if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",job).isEmpty())throw bad("Für diesen Auftrag läuft bereits eine Rechnungszuordnung.");if(!db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",job).isEmpty())throw bad("Auftrag bereits zugeordnet.");var shares=db.queryForList("SELECT DISTINCT c.cs_id FROM tourservice t JOIN costcenter c ON c.csc_id=t.csc_id WHERE t.jb_id=?",job);if(shares.stream().anyMatch(part->id(part,"cs_id")!=a.query().customer()))throw bad("Auftrag #"+job+" verteilt Kosten auf mehrere Kunden. Eine einzelne Rechnungsnummer darf diese Anteile nicht zusammenfassen.");items.add(new Assignment(job,assignmentRevision(job)));db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_invoice_lock","gdc_content",a.requestId(),"gdc_context","PENDING"));}
|
||||
if(!db.queryForList("SELECT i.jb_id FROM invoice i JOIN job j ON j.jb_id=i.jb_id LEFT JOIN costcenter c ON c.csc_id=j.csc_id_payer WHERE i.inv_no=? AND (j.hq_id<>? OR COALESCE(c.cs_id,0)<>?) LIMIT 1",number,u.headquartersId(),a.query().customer()).isEmpty())throw bad("Diese Rechnungsnummer gehört bereits zu einem anderen Kunden oder einer anderen Niederlassung.");
|
||||
// MyISAM cannot roll back. Save a durable intent before touching the invoice table.
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","nginv","gdc_obj_id",u.headquartersId(),"gdc_gen_fieldname",a.requestId(),"gdc_content",support.encode(new Operation(u.userId(),u.headquartersId(),number,a.date().toString(),items,hash)),"gdc_context","PENDING"));return null;});
|
||||
return resume(u,a.requestId());
|
||||
}
|
||||
String assignmentRevision(long job){var j=new LinkedHashMap<>(one(db.queryForList("SELECT * FROM job WHERE jb_id=?",job)));j.remove("jb_locktime");j.remove("jb_lockuser");j.remove("jb_modify");return revision(List.of(j,db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY csc_id,tr_sort,srv_id,srvt_id",job),db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",job)));}
|
||||
Map<String,Object> operation(String token){var rows=db.queryForList("SELECT * FROM genericdatacontainer WHERE gdc_obj_type='nginv' AND gdc_gen_fieldname=?",token(token));return rows.isEmpty()?null:one(rows);}
|
||||
Operation decode(Map<String,Object> row){try{return support.json.readValue(text(row,"gdc_content"),Operation.class);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
public Map<String,Object> resume(LegacyUser u,String token){u.require(5);u.require(7);token(token);return db.writeTransaction(()->{lockHeadquarters(db,u);var row=operation(token);if(row==null)throw bad("Rechnungsvorgang nicht gefunden.");var op=decode(row);if(op.hq()!=u.headquartersId())throw bad("Andere Niederlassung.");if(text(row,"gdc_context").equals("STOPPED"))throw bad("Der Vorgang wurde beendet. Bitte die aktuelle Zuordnung neu laden.");if(text(row,"gdc_context").equals("DONE"))return fields("state","DONE","number",op.number());
|
||||
for(var item:op.items()){
|
||||
one(db.lockRows("SELECT jb_id FROM job WHERE jb_id=? AND hq_id=?",item.id(),u.headquartersId()));
|
||||
if(!item.revision().equals(assignmentRevision(item.id())))throw bad("Auftrag #"+item.id()+" wurde seit Beginn der Zuordnung geändert. Der Vorgang bleibt zur Prüfung offen.");
|
||||
// Single-row compare-and-insert preserves PHP changes and makes retries safe.
|
||||
db.update("INSERT INTO invoice (jb_id,inv_no,inv_datetime) SELECT ?,?,? WHERE NOT EXISTS (SELECT jb_id FROM invoice WHERE jb_id=?)",item.id(),op.number(),LocalDate.parse(op.date()).atTime(12,0),item.id());
|
||||
var stored=one(db.queryForList("SELECT inv_no,inv_datetime FROM invoice WHERE jb_id=?",item.id()));if(!text(stored,"inv_no").equals(op.number())||!dateTime(stored,"inv_datetime").toLocalDate().equals(LocalDate.parse(op.date())))throw bad("Auftrag #"+item.id()+" wurde anderweitig zugeordnet. Der Vorgang bleibt zur Prüfung offen.");
|
||||
}
|
||||
for(var item:op.items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);
|
||||
support.audit(u,0,0,0,2,"Rechnungsnummer "+op.number()+" zugeordnet · "+op.items().size()+" Aufträge",token);db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(row,"gdc_id"));return fields("state","DONE","number",op.number());});}
|
||||
public Map<String,Object> stop(LegacyUser u,String token){u.require(5);u.require(7);token(token);return db.writeTransaction(()->{lockHeadquarters(db,u);var row=operation(token);if(row==null||decode(row).hq()!=u.headquartersId())throw bad("Vorgang nicht gefunden.");if(text(row,"gdc_context").equals("DONE"))throw bad("Vorgang ist bereits abgeschlossen.");for(var item:decode(row).items())db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock' AND gdc_content=?",item.id(),token);db.update("UPDATE genericdatacontainer SET gdc_context='STOPPED' WHERE gdc_id=?",id(row,"gdc_id"));support.audit(u,0,0,0,2,"Rechnungszuordnung beendet; bereits gespeicherte Zuordnungen bleiben erhalten",token);return fields("state","STOPPED");});}
|
||||
public byte[] csv(LegacyUser u,Query q){var data=query(u,q);StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Kostenstelle;Kunde;Fahrer;Erledigt;Rechnungsnummer;Netto;MwSt;Brutto;Zahlungsbedingung;Zahlungsbedingung-Code;Text\n");for(var r:(List<Map<String,Object>>)data.get("rows")){for(String k:List.of("id","centerName","customer","courier","finished","invoice","net","tax","gross","paymentTerm","paymentTermCode","description"))csv.append(ApiController.csvCell(r.get(k))).append(';');csv.append('\n');}return csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.http.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class BusinessController {
|
||||
final MasterData masters;final OrderOperations orders;final BillingService billing;
|
||||
public BusinessController(MasterData masters,OrderOperations orders,BillingService billing){this.masters=masters;this.orders=orders;this.billing=billing;}
|
||||
@GetMapping("/master-data/{kind}/{id}") public Object master(Authentication a,@PathVariable String kind,@PathVariable long id,@RequestParam(defaultValue="0")long parent){return masters.view(ApiController.user(a),kind,id,parent);}
|
||||
@PutMapping("/master-data/{kind}/{id}") public Object saveMaster(Authentication a,@PathVariable String kind,@PathVariable long id,@RequestParam(defaultValue="0")long parent,@RequestBody MasterData.Save value){return masters.save(ApiController.user(a),kind,id,parent,value);}
|
||||
@GetMapping("/orders/{id}/workbench") public Object order(Authentication a,@PathVariable long id){return orders.view(ApiController.user(a),id);}
|
||||
@PutMapping("/orders/{id}/workbench") public Object edit(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Edit value){return orders.edit(ApiController.user(a),id,value);}
|
||||
@PutMapping("/orders/{id}/price") public Object price(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Price value){return orders.price(ApiController.user(a),id,value);}
|
||||
@PostMapping("/orders/{id}/actions") public Object action(Authentication a,@PathVariable long id,@RequestBody OrderOperations.Action value){return orders.action(ApiController.user(a),id,value);}
|
||||
@GetMapping("/billing/options") public Object options(Authentication a){return billing.options(ApiController.user(a));}
|
||||
@GetMapping("/billing/centers") public Object centers(Authentication a,@RequestParam long customer){return billing.centers(ApiController.user(a),customer);}
|
||||
@PostMapping("/billing/query") public Object query(Authentication a,@RequestBody BillingService.Query q){return billing.query(ApiController.user(a),q);}
|
||||
@PostMapping("/billing/assign") public Object assign(Authentication a,@RequestBody BillingService.Assign value){return billing.assign(ApiController.user(a),value);}
|
||||
@PostMapping("/billing/operations/{token}/resume") public Object resume(Authentication a,@PathVariable String token){return billing.resume(ApiController.user(a),token);}
|
||||
@PostMapping("/billing/operations/{token}/stop") public Object stop(Authentication a,@PathVariable String token){return billing.stop(ApiController.user(a),token);}
|
||||
@PostMapping("/billing/export.csv") public ResponseEntity<byte[]> csv(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abrechnung.csv").body(billing.csv(ApiController.user(a),q));}
|
||||
@PostMapping("/billing/statement.pdf") public ResponseEntity<byte[]> pdf(Authentication a,@RequestBody BillingService.Query q){return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=rechnungsanlage.pdf").body(StatementPdf.create(billing.query(ApiController.user(a),q),q));}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Receipts and audit outbox use the existing transactional generic data container. */
|
||||
@Service
|
||||
public class BusinessSupport {
|
||||
final DatabaseService db;
|
||||
final ObjectMapper json;
|
||||
final String historySchema;
|
||||
public BusinessSupport(DatabaseService db,ObjectMapper json){this(db,json,"phoenix_log");}
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public BusinessSupport(DatabaseService db,ObjectMapper json,@org.springframework.beans.factory.annotation.Value("${app.history-schema:phoenix_log}") String schema){this.db=db;this.json=json;if(!schema.matches("[A-Za-z0-9_]+"))throw new IllegalArgumentException("Ungültiges Historienschema");this.historySchema="`"+schema+"`";}
|
||||
record Audit(long headquarters,long user,long job,long customer,long courier,int operation,String description,String at){}
|
||||
String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
static String token(String value){try{String normalized=UUID.fromString(value).toString();if(!normalized.equals(value))throw new IllegalArgumentException();return normalized;}catch(Exception e){throw bad("Die Vorgangskennung fehlt. Bitte die Ansicht neu öffnen.");}}
|
||||
Long receipt(LegacyUser user,String token,Object request){
|
||||
var rows=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngop' AND gdc_obj_id=? AND gdc_gen_fieldname=?",user.userId(),token(token));
|
||||
if(rows.isEmpty())return null;
|
||||
var row=one(rows);if(!text(row,"gdc_context").equals(revision(List.of(user.headquartersId(),request))))throw bad("Diese Vorgangskennung wurde bereits für andere Daten verwendet.");
|
||||
return Long.valueOf(text(row,"gdc_content"));
|
||||
}
|
||||
void receipt(LegacyUser user,String token,Object request,long result){db.insert("genericdatacontainer",fields("gdc_obj_type","ngop","gdc_obj_id",user.userId(),"gdc_gen_fieldname",token(token),"gdc_content",Long.toString(result),"gdc_context",revision(List.of(user.headquartersId(),request))));}
|
||||
void audit(LegacyUser user,long job,long customer,long courier,int operation,String description,String token){
|
||||
var event=new Audit(user.headquartersId(),user.userId(),job,customer,courier,operation,description+" | NG="+token,LocalDateTime.now().withNano(0).toString());
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","ngaudit","gdc_obj_id",user.headquartersId(),"gdc_gen_fieldname",token,"gdc_content",encode(event),"gdc_context","PENDING"));
|
||||
}
|
||||
@Scheduled(initialDelay=60000,fixedDelay=30000)
|
||||
public void publish(){
|
||||
try {for(var pending:db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='ngaudit' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 50")) {
|
||||
db.writeTransaction(()->{var rows=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_id=? AND gdc_context='PENDING'",id(pending,"gdc_id"));if(rows.isEmpty())return null;
|
||||
Audit e;try{e=json.readValue(text(rows.getFirst(),"gdc_content"),Audit.class);}catch(Exception ex){throw new IllegalStateException(ex);}
|
||||
if(db.queryForList("SELECT log_id FROM "+historySchema+".log WHERE hq_id=? AND usr_id=? AND logo_description=? LIMIT 1",e.headquarters(),e.user(),e.description()).isEmpty())
|
||||
db.update("INSERT INTO "+historySchema+".log (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,?,?,?,'',?,0,0,0,?,?)",e.operation(),e.headquarters(),e.job(),e.user(),e.courier(),e.customer(),e.description(),LocalDateTime.parse(e.at()));
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=?",id(pending,"gdc_id"));return null;});
|
||||
}}catch(RuntimeException ignored){/* Durable pending events remain available for the next run. */}
|
||||
}
|
||||
void transactional(String... tables) {
|
||||
for(String table:tables){String[] parts=table.split("\\.");String schema=parts.length==2?parts[0]:null;String name=parts[parts.length-1];
|
||||
Integer count=db.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA="+(schema==null?"DATABASE()":"?")+" AND TABLE_NAME=? AND ENGINE='InnoDB'",Integer.class,schema==null?new Object[]{name}:new Object[]{schema,name});
|
||||
if(count==null||count!=1)throw bad("Speichern benötigt eine transaktionale InnoDB-Tabelle: "+table);
|
||||
}
|
||||
}
|
||||
static void update(DatabaseService db,String table,String key,long id,Map<String,Object> values){if(values.isEmpty())return;var args=new ArrayList<>(values.values());args.add(id);db.update("UPDATE "+table+" SET "+String.join(",",values.keySet().stream().map(k->k+"=?").toList())+" WHERE "+key+"=?",args.toArray());}
|
||||
static void insertWithoutKey(DatabaseService db,String table,Map<String,Object> values){db.update("INSERT INTO "+table+" ("+String.join(",",values.keySet())+") VALUES ("+String.join(",",Collections.nCopies(values.size(),"?"))+")",values.values().toArray());}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Native counterparts of admin/public_holiday.php and admin/traveltime.php. */
|
||||
@Service
|
||||
public class CalendarAdministration {
|
||||
private final DatabaseService db;
|
||||
private final Clock clock;
|
||||
@Autowired public CalendarAdministration(DatabaseService db){this(db,Clock.system(ZoneId.of("Europe/Berlin")));}
|
||||
CalendarAdministration(DatabaseService db,Clock clock){this.db=db;this.clock=clock;}
|
||||
record Holiday(int month,int day,String name) {}
|
||||
record HolidaySave(String revision,List<Holiday> days) {}
|
||||
record TravelSave(String revision,Integer minutes) {}
|
||||
enum TravelMode { CLASSIC,BWV }
|
||||
private List<Map<String,Object>> holidayRows(LegacyUser user,int year) {return holidayRows(user,year,false);}
|
||||
private List<Map<String,Object>> holidayRows(LegacyUser user,int year,boolean lock) {
|
||||
if(year!=0 && (year<1900 || year>2200))throw bad("Ungültiges Jahr.");
|
||||
String sql="SELECT ph_id,ph_month,ph_day,ph_name,ph_time_from,ph_time_to FROM publicholiday WHERE hq_id=? AND ph_year=? ORDER BY ph_month,ph_day,ph_time_from,ph_id";
|
||||
return lock?db.lockRows(sql,user.headquartersId(),year):db.queryForList(sql,user.headquartersId(),year);
|
||||
}
|
||||
public Map<String,Object> holidays(LegacyUser user,int year) {
|
||||
user.require(2);
|
||||
return db.readTransaction(()->{
|
||||
var rows=holidayRows(user,year);
|
||||
var days=new LinkedHashMap<String,Holiday>();
|
||||
for(var r:rows)days.putIfAbsent(id(r,"ph_month")+"-"+id(r,"ph_day"),new Holiday((int)id(r,"ph_month"),(int)id(r,"ph_day"),text(r,"ph_name")));
|
||||
return fields("year",year,"today",LocalDate.now(clock),"revision",revision(rows),"days",List.copyOf(days.values()),
|
||||
"years",db.queryForList("SELECT DISTINCT ph_year AS year FROM publicholiday WHERE hq_id=? ORDER BY ph_year DESC",user.headquartersId()));
|
||||
});
|
||||
}
|
||||
public Map<String,Object> saveHolidays(LegacyUser user,int year,HolidaySave request) {
|
||||
user.require(2);
|
||||
LocalDate today=LocalDate.now(clock);
|
||||
if(year<today.getYear() || year>2200)throw bad("Vergangene Jahre und die Default-Vorlage können nicht geändert werden.");
|
||||
if(request.days()==null || request.days().size()>366)throw bad("Ungültige Feiertagsliste.");
|
||||
var requested=new LinkedHashMap<LocalDate,String>();
|
||||
for(var day:request.days()) {
|
||||
if(day==null)throw bad("Ungültiger Feiertag.");
|
||||
LocalDate date;
|
||||
try {date=LocalDate.of(year,day.month(),day.day());}catch(DateTimeException e){throw bad("Ungültiges Feiertagsdatum.");}
|
||||
String name=day.name()==null?"":day.name().strip();
|
||||
if(name.length()>50)throw bad("Feiertagsname: höchstens 50 Zeichen.");
|
||||
if(requested.putIfAbsent(date,name)!=null)throw bad("Ein Feiertag ist mehrfach enthalten.");
|
||||
}
|
||||
return db.writeTransaction(()->{
|
||||
lockHeadquarters(db,user);
|
||||
var rows=holidayRows(user,year,true);unchanged(rows,request.revision());
|
||||
var existing=new HashMap<LocalDate,List<Map<String,Object>>>();
|
||||
for(var row:rows)existing.computeIfAbsent(LocalDate.of(year,(int)id(row,"ph_month"),(int)id(row,"ph_day")),x->new ArrayList<>()).add(row);
|
||||
// Preserve every historical row, including optional time windows. The PHP UI disables these days.
|
||||
for(var item:requested.entrySet())if(!item.getKey().isAfter(today)) {
|
||||
var old=existing.get(item.getKey());
|
||||
if(old==null || old.stream().noneMatch(r->text(r,"ph_name").equals(item.getValue())))throw bad("Vergangene Tage und heute dürfen nicht geändert werden.");
|
||||
}
|
||||
for(var item:existing.entrySet()) {
|
||||
if(!item.getKey().isAfter(today))continue;
|
||||
String name=requested.get(item.getKey());
|
||||
for(var row:item.getValue()) {
|
||||
if(name==null)db.update("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?",id(row,"ph_id"),user.headquartersId());
|
||||
else if(!name.equals(text(item.getValue().getFirst(),"ph_name")) && !name.equals(text(row,"ph_name")))db.update("UPDATE publicholiday SET ph_name=? WHERE ph_id=? AND hq_id=?",name,id(row,"ph_id"),user.headquartersId());
|
||||
}
|
||||
}
|
||||
for(var item:requested.entrySet())if(item.getKey().isAfter(today) && !existing.containsKey(item.getKey()))
|
||||
db.insert("publicholiday",fields("hq_id",user.headquartersId(),"ph_year",year,"ph_month",item.getKey().getMonthValue(),"ph_day",item.getKey().getDayOfMonth(),"ph_name",item.getValue()));
|
||||
return holidays(user,year);
|
||||
});
|
||||
}
|
||||
public Map<String,Object> travelTimes(LegacyUser user,TravelMode mode,String from,String to) {
|
||||
user.require(2);required(from,10,"PLZ von");required(to,10,"PLZ bis");
|
||||
if(from.compareTo(to)>0)throw bad("Der PLZ-Bereich ist umgekehrt.");
|
||||
var rows=db.queryForList("SELECT p.srvp_id AS id,p.srvp_plz AS postalCode,p.srvp_country AS country,p.srvp_traveltime AS globalMinutes,t.srvpt_traveltime AS localMinutes FROM serviceplz p LEFT JOIN serviceplztraveltime t ON t.srvp_id=p.srvp_id AND t.hq_id=? WHERE p.srvp_valid=1 AND p.srvp_plz>=? AND p.srvp_plz<=? ORDER BY p.srvp_plz,p.srvp_id LIMIT 4001",user.headquartersId(),from,to);
|
||||
if(rows.size()>4000)throw bad("Mehr als 4000 PLZ gefunden. Bitte den Bereich eingrenzen.");
|
||||
return fields("mode",mode,"defaultMinutes",defaultTravelTime(user),"items",rows.stream().map(r->travel(r,mode)).toList());
|
||||
}
|
||||
private Map<String,Object> travel(Map<String,Object> row,TravelMode mode) {
|
||||
Object minutes=value(row,mode==TravelMode.BWV?"globalMinutes":"localMinutes");
|
||||
return fields("id",id(row,"id"),"postalCode",text(row,"postalCode"),"country",text(row,"country"),"minutes",minutes,"revision",revision(Arrays.asList(id(row,"id"),mode,minutes)));
|
||||
}
|
||||
Integer defaultTravelTime(LegacyUser user) {
|
||||
String configured=new OrderEntryCatalog(db).parameter(user.headquartersId(),"LATEST_TAKETIME_IN_MINUTES");
|
||||
try {int minutes=Integer.parseInt(configured.strip());return minutes>=1&&minutes<=9999?minutes:null;}
|
||||
catch(NumberFormatException e){return null;}
|
||||
}
|
||||
public Map<String,Object> saveTravelTime(LegacyUser user,TravelMode mode,long postalId,TravelSave request) {
|
||||
user.require(2);
|
||||
if(request==null||request.minutes()!=null&&(request.minutes()<0||request.minutes()>9999))throw bad("Anfahrtszeit: Bitte 0 bis 9999 Minuten eingeben.");
|
||||
Integer minutes=request.minutes()==null||request.minutes()==0?defaultTravelTime(user):request.minutes();
|
||||
if(minutes==null)throw bad("Für diese Niederlassung fehlt ein gültiger Standard LATEST_TAKETIME_IN_MINUTES. Bitte eine Anfahrtszeit eingeben.");
|
||||
return db.writeTransaction(()->{
|
||||
lockHeadquarters(db,user);
|
||||
if(mode==TravelMode.CLASSIC)db.lockRows("SELECT srvp_id FROM serviceplztraveltime WHERE srvp_id=? AND hq_id=?",postalId,user.headquartersId());
|
||||
var row=one(db.queryForList("SELECT p.srvp_id AS id,p.srvp_plz AS postalCode,p.srvp_country AS country,p.srvp_traveltime AS globalMinutes,t.srvpt_traveltime AS localMinutes FROM serviceplz p LEFT JOIN serviceplztraveltime t ON t.srvp_id=p.srvp_id AND t.hq_id=? WHERE p.srvp_valid=1 AND p.srvp_id=?",user.headquartersId(),postalId));
|
||||
Object old=value(row,mode==TravelMode.BWV?"globalMinutes":"localMinutes");
|
||||
unchanged(Arrays.asList(postalId,mode,old),request.revision());
|
||||
if(mode==TravelMode.BWV) {
|
||||
// MyISAM cannot roll back a batch. One conditional UPDATE is the complete business write.
|
||||
if(db.update("UPDATE serviceplz SET srvp_traveltime=? WHERE srvp_id=? AND srvp_valid=1 AND srvp_traveltime <=> ?",minutes,postalId,old)!=1)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Anfahrtszeit wurde zwischenzeitlich geändert.");
|
||||
}else {
|
||||
if(old==null)db.update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",postalId,user.headquartersId(),minutes);
|
||||
else db.update("UPDATE serviceplztraveltime SET srvpt_traveltime=? WHERE srvp_id=? AND hq_id=?",minutes,postalId,user.headquartersId());
|
||||
}
|
||||
row.put(mode==TravelMode.BWV?"globalMinutes":"localMinutes",minutes);
|
||||
return travel(row,mode);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import java.time.*;
|
||||
import java.time.format.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CrmScope.marks;
|
||||
@Service
|
||||
class ContactService {
|
||||
private final DatabaseService db;private final CrmScope scope;
|
||||
ContactService(DatabaseService db,CrmScope scope){this.db=db;this.scope=scope;}
|
||||
record Query(List<Long> headquarters,String search,boolean prefix,String letter,Long customerId){}
|
||||
record Save(long headquarters,String revision,String schemaRevision,Map<Long,String> values){}
|
||||
private List<Map<String,Object>> definitions(long hq){return db.queryForList("SELECT ck.mtfck_id AS id,ck.mtfk_id AS keyId,ck.mtfck_history AS history,k.mtfk_name AS label,k.mtfk_type AS type FROM metafieldcategorykey ck JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id",hq);}
|
||||
Map<String,Object> schema(LegacyUser u,long hq){scope.checked(u,List.of(hq));var d=definitions(hq);if(d.isEmpty())throw bad("Für diese Niederlassung sind keine Kontaktfelder eingerichtet.");return fields("fields",d,"revision",revision(d));}
|
||||
private List<Map<String,Object>> raw(long id,List<Map<String,Object>> defs){var args=new ArrayList<Object>(List.of(id));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());}
|
||||
private Map<String,String> customerLabels(LegacyUser u,Collection<String> ids){var valid=ids.stream().filter(v->v!=null&&v.matches("[1-9][0-9]{0,9}")).distinct().toList();if(valid.isEmpty())return Map.of();var hqs=scope.headquarters(u).stream().map(h->id(h,"id")).toList();if(hqs.isEmpty())return Map.of();var args=new ArrayList<Object>(valid);args.addAll(hqs);var result=new HashMap<String,String>();for(var r:db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' ',cs.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer cs LEFT JOIN company co ON co.cmp_id=cs.cmp_id WHERE cs.cs_id IN ("+marks(valid.size())+") AND cs.hq_id IN ("+marks(hqs.size())+")",args.toArray()))result.put(Long.toString(id(r,"id")),text(r,"label"));return result;}
|
||||
Map<String,Object> get(LegacyUser u,long hq,long contact){scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Keine Kontaktfelder eingerichtet.");var rows=raw(contact,defs);if(rows.isEmpty())one(List.of());var vals=new LinkedHashMap<Long,String>();for(var r:rows)if(vals.put(id(r,"mtfck_id"),text(r,"mtfv_value"))!=null)throw bad("Doppelte Kontaktfelder im Bestand. Bitte zuerst bereinigen.");return fields("id",contact,"headquartersId",hq,"values",vals,"customerLabels",customerLabels(u,defs.stream().filter(d->text(d,"type").equals("co2cs")).map(d->vals.get(id(d,"id"))).toList()),"revision",revision(rows),"schema",fields("fields",defs,"revision",revision(defs)));}
|
||||
List<Map<String,Object>> list(LegacyUser u,Query q){if(q==null)throw bad("Kontaktfilter fehlen.");var h=scope.checked(u,q.headquarters());String term=q.search()==null?"":q.search().strip();if(term.length()>100||q.letter()!=null&&!q.letter().matches("[A-ZÄÖÜ]?"))throw bad("Ungültige Kontaktsuche.");if(q.customerId()!=null)scope.customer(u,q.customerId());
|
||||
var args=new ArrayList<Object>(h);String where="ck.mtfc_id=1 AND ck.hq_id IN ("+marks(h.size())+")";
|
||||
if(!term.isEmpty()){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue sv JOIN metafieldcategorykey sk ON sk.mtfck_id=sv.mtfck_id JOIN metafieldkey fk ON fk.mtfk_id=sk.mtfk_id LEFT JOIN customer c ON fk.mtfk_type='co2cs' AND c.cs_id=sv.mtfv_value AND c.hq_id=sk.hq_id LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE sv.mtfv_id=v.mtfv_id AND sk.mtfc_id=1 AND sk.hq_id=ck.hq_id AND (sv.mtfv_value LIKE ? OR CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ?))";String like=(q.prefix()?"":"%")+term.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";args.add(like);args.add(like);}
|
||||
if(q.letter()!=null&&!q.letter().isEmpty()){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue lv JOIN metafieldcategorykey lk ON lk.mtfck_id=lv.mtfck_id JOIN metafieldkey k ON k.mtfk_id=lk.mtfk_id WHERE lv.mtfv_id=v.mtfv_id AND lk.mtfc_id=1 AND lk.hq_id=ck.hq_id AND (k.mtfk_name='Name' OR k.mtfk_type='surname') AND lv.mtfv_value LIKE ?)";args.add(q.letter()+"%");}
|
||||
if(q.customerId()!=null){where+=" AND EXISTS (SELECT 1 FROM metafieldvalue cv JOIN metafieldcategorykey ck2 ON ck2.mtfck_id=cv.mtfck_id JOIN metafieldkey k2 ON k2.mtfk_id=ck2.mtfk_id WHERE cv.mtfv_id=v.mtfv_id AND ck2.mtfc_id=1 AND ck2.hq_id=ck.hq_id AND k2.mtfk_type='co2cs' AND cv.mtfv_value=?)";args.add(q.customerId().toString());}
|
||||
var matches=db.queryForList("SELECT DISTINCT v.mtfv_id AS id,ck.hq_id AS headquartersId FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id WHERE "+where+" ORDER BY v.mtfv_id DESC LIMIT 501",args.toArray());if(matches.size()>500)throw bad("Mehr als 500 Kontakte. Bitte Suche oder Niederlassungen eingrenzen.");
|
||||
var result=new ArrayList<Map<String,Object>>();for(long hq:h){var ids=matches.stream().filter(m->id(m,"headquartersId")==hq).map(m->id(m,"id")).toList();if(ids.isEmpty())continue;var p=new ArrayList<Object>(List.of(hq));p.addAll(ids);var rows=db.queryForList("SELECT v.mtfv_id AS id,ck.mtfck_id AS fieldId,k.mtfk_name AS label,k.mtfk_type AS type,v.mtfv_value AS value FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=1 AND ck.hq_id=? AND v.mtfv_id IN ("+marks(ids.size())+") ORDER BY ck.mtfck_sort,ck.mtfck_id",p.toArray());for(long cid:ids){var values=rows.stream().filter(r->id(r,"id")==cid).toList();result.add(fields("id",cid,"headquartersId",hq,"fields",values));}}
|
||||
var linked=customerLabels(u,result.stream().flatMap(r->((List<Map<String,Object>>)r.get("fields")).stream()).filter(f->text(f,"type").equals("co2cs")).map(f->text(f,"value")).toList());for(var r:result)for(var f:(List<Map<String,Object>>)r.get("fields"))if(text(f,"type").equals("co2cs"))f.put("displayValue",linked.getOrDefault(text(f,"value"),"Kunde "+text(f,"value")));
|
||||
result.sort(Comparator.comparing(r->contactName((List<Map<String,Object>>)r.get("fields")),String.CASE_INSENSITIVE_ORDER));return result;
|
||||
}
|
||||
static String contactName(List<Map<String,Object>> fields){return String.join(" ",fields.stream().filter(f->Set.of("Name","Vorname").contains(text(f,"label"))).map(f->text(f,"value")).toList());}
|
||||
Map<String,Object> save(LegacyUser u,Long cid,Save s){if(s==null||s.values()==null)throw bad("Kontaktdaten fehlen.");scope.checked(u,List.of(s.headquarters()));var defs=definitions(s.headquarters());unchanged(defs,s.schemaRevision());if(defs.isEmpty()||s.values().size()>200)throw bad("Ungültige Kontaktfelder.");var known=defs.stream().map(d->id(d,"id")).toList();if(!known.containsAll(s.values().keySet()))throw bad("Unbekanntes Kontaktfeld.");var vals=new LinkedHashMap<Long,String>();var before=cid==null?List.<Map<String,Object>>of():raw(cid,defs);if(cid!=null){if(before.isEmpty())one(before);unchanged(before,s.revision());}
|
||||
for(var d:defs){String v=s.values().getOrDefault(id(d,"id"),"");if(v==null)v="";v=v.strip();if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Kontaktfeld ist zu lang.");String type=text(d,"type");String oldValue=before.stream().filter(r->id(r,"mtfck_id")==id(d,"id")).map(r->text(r,"mtfv_value")).findFirst().orElse("");boolean modified=!v.equals(oldValue);if(modified&&type.equals("co2cs")&&!v.isBlank()){try{scope.customer(u,Long.parseLong(v));}catch(NumberFormatException e){throw bad("Bitte einen Kunden auswählen.");}}
|
||||
if(modified&&(type.equals("date")||type.equals("birthdate"))&&!v.isBlank()){try{LocalDate.parse(v,DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT));}catch(DateTimeParseException e){throw bad(text(d,"label")+": Datum bitte als TT.MM.JJJJ eingeben.");}}
|
||||
vals.put(id(d,"id"),v);}
|
||||
if(vals.values().stream().allMatch(String::isBlank))throw bad("Bitte mindestens ein Kontaktfeld ausfüllen.");
|
||||
return db.contactWriteTransaction(()->{unchanged(definitions(s.headquarters()),s.schemaRevision());List<Map<String,Object>> old=cid==null?List.of():raw(cid,defs);if(cid!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}long contact=cid==null?db.queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class):cid;if(contact<=0||contact>Integer.MAX_VALUE)throw bad("Kontakt-ID-Bereich ausgeschöpft.");
|
||||
if(cid!=null){var a=new ArrayList<Object>(List.of(contact));a.addAll(known);db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(known.size())+")",a.toArray());}
|
||||
for(var d:defs){long key=id(d,"id");String v=vals.get(key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,contact,v);String previous=id(d,"history")==1?db.queryForList("SELECT mtfv_value FROM metafieldvaluehistory WHERE mtfck_id=? AND mtfv_id=? ORDER BY mtfvh_createtime DESC LIMIT 1",key,contact).stream().map(r->text(r,"mtfv_value")).findFirst().orElse(""):v;if(id(d,"history")==1&&!previous.equals(v))db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,contact,v,LocalDateTime.now(ZoneId.of("Europe/Berlin")));}
|
||||
return fields("id",contact);});
|
||||
}
|
||||
void remove(LegacyUser u,long hq,long cid,String rev){scope.checked(u,List.of(hq));var defs=definitions(hq);if(defs.isEmpty())throw bad("Keine Kontaktfelder.");db.contactWriteTransaction(()->{var current=definitions(hq);unchanged(current,revision(defs));var old=raw(cid,defs);if(old.isEmpty())one(old);unchanged(old,rev);var a=new ArrayList<Object>(List.of(cid));a.addAll(defs.stream().map(d->id(d,"id")).toList());db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+")",a.toArray());return null;});}
|
||||
List<Map<String,Object>> history(LegacyUser u,long hq,long cid){get(u,hq,cid);var defs=definitions(hq);var a=new ArrayList<Object>(List.of(cid));a.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id AS fieldId,mtfv_value AS value,mtfvh_createtime AS time FROM metafieldvaluehistory WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfvh_createtime DESC LIMIT 1000",a.toArray());}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController
|
||||
@RequestMapping("/api/crm")
|
||||
class CrmController {
|
||||
private final ContactService contacts;private final SalesAnalytics sales;
|
||||
CrmController(ContactService contacts,SalesAnalytics sales){this.contacts=contacts;this.sales=sales;}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication a){return sales.options(user(a));}
|
||||
@GetMapping("/contacts/schema") Map<String,Object> schema(Authentication a,@RequestParam long headquarters){return contacts.schema(user(a),headquarters);}
|
||||
@PostMapping("/contacts/query") List<Map<String,Object>> contacts(Authentication a,@RequestBody ContactService.Query q){return contacts.list(user(a),q);}
|
||||
@GetMapping("/contacts/{id}") Map<String,Object> contact(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.get(user(a),headquarters,id);}
|
||||
@GetMapping("/contacts/{id}/history") List<Map<String,Object>> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return contacts.history(user(a),headquarters,id);}
|
||||
@PostMapping("/contacts") Map<String,Object> create(Authentication a,@RequestBody ContactService.Save s){return contacts.save(user(a),null,s);}
|
||||
@PutMapping("/contacts/{id}") Map<String,Object> save(Authentication a,@PathVariable long id,@RequestBody ContactService.Save s){return contacts.save(user(a),id,s);}
|
||||
record Delete(long headquarters,String revision){}
|
||||
@DeleteMapping("/contacts/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication a,@PathVariable long id,@RequestBody Delete s){contacts.remove(user(a),s.headquarters(),id,s.revision());}
|
||||
@PostMapping("/switches") Map<String,Object> switches(Authentication a,@RequestBody SalesAnalytics.Query q){return sales.switches(user(a),q);}
|
||||
record Motivation(String month,List<Long> headquarters){}
|
||||
@PostMapping("/motivation") Map<String,Object> motivation(Authentication a,@RequestBody Motivation q){return sales.motivation(user(a),q.month(),q.headquarters());}
|
||||
@PostMapping("/targets/query") List<Map<String,Object>> targets(Authentication a,@RequestBody SalesAnalytics.TargetAccess s){return sales.targets(user(a),s);}
|
||||
@PostMapping("/targets") Map<String,Object> target(Authentication a,@RequestBody SalesAnalytics.Target s){return sales.target(user(a),s);}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
@Service
|
||||
class CrmScope {
|
||||
final DatabaseService db;
|
||||
CrmScope(DatabaseService db){this.db=db;}
|
||||
void access(LegacyUser u){u.require(0);u.require(11);}
|
||||
List<Map<String,Object>> headquarters(LegacyUser u){access(u);return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(u.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",u.hasRight(10)?new Object[]{u.headquartersId()}:new Object[]{u.headquartersId(),u.headquartersId()});}
|
||||
List<Long> checked(LegacyUser u,List<Long> hqs){access(u);if(hqs==null||hqs.isEmpty()||hqs.size()>100||hqs.stream().anyMatch(Objects::isNull))throw bad("Bitte Niederlassungen auswählen.");var allowed=headquarters(u).stream().map(r->id(r,"id")).toList();if(!allowed.containsAll(hqs))throw new AccessDeniedException("Niederlassung nicht freigegeben.");return hqs.stream().distinct().sorted().toList();}
|
||||
static String marks(int n){return String.join(",",Collections.nCopies(n,"?"));}
|
||||
void customer(LegacyUser u,long cs){var h=checked(u,headquarters(u).stream().map(r->id(r,"id")).toList());var args=new ArrayList<Object>(h);args.add(cs);one(db.queryForList("SELECT cs_id FROM customer WHERE hq_id IN ("+marks(h.size())+") AND cs_id=?",args.toArray()));}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Ordered customer_service.php assignments, including resumable MyISAM writes. */
|
||||
@RestController @RequestMapping("/api/master/customers/{id}/services")
|
||||
class CustomerServices {
|
||||
final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;
|
||||
CustomerServices(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog){this.db=db;this.support=support;this.catalog=catalog;}
|
||||
record Assignment(long id,int sort){}
|
||||
record Save(String requestId,String revision,List<Long> services,List<Long> types){}
|
||||
record Plan(long user,long headquarters,long customer,Save request,List<Assignment> beforeServices,List<Assignment> beforeTypes){}
|
||||
@GetMapping Map<String,Object> get(Authentication a,@PathVariable long id){return view(ApiController.user(a),id);}
|
||||
@PutMapping Map<String,Object> put(Authentication a,@PathVariable long id,@RequestBody Save s){return save(ApiController.user(a),id,s);}
|
||||
@PostMapping("/operations/{token}") Map<String,Object> resume(Authentication a,@PathVariable long id,@PathVariable String token){return resume(ApiController.user(a),id,token);}
|
||||
private void access(LegacyUser u,long customer){u.require(0);u.require(2);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));}
|
||||
private List<Assignment> assignments(long customer,boolean types){String table=types?"customerservicetype":"customerservice",key=types?"srvt_id":"srv_id",sort=types?"cssrvt_sort":"cssrv_sort";return db.queryForList("SELECT "+key+" AS id,"+sort+" AS sort FROM "+table+" WHERE cs_id=? ORDER BY "+sort+","+key,customer).stream().map(r->new Assignment(id(r,"id"),(int)id(r,"sort"))).toList();}
|
||||
private List<Map<String,Object>> choices(LegacyUser u,boolean types){String table=types?"servicetype":"service",prefix=types?"srvt":"srv";var result=new ArrayList<Map<String,Object>>();for(int mode:List.of(0,1)){long hq=catalog.enabled(u.headquartersId(),mode==0?"MANDATOR_SERVICE_ENABLED":"MANDATOR_SERVICE2_ENABLED")?u.headquartersId():0;result.addAll(db.queryForList("SELECT "+prefix+"_id AS id,"+prefix+"_name AS label,"+prefix+"_mode AS mode FROM "+table+" WHERE hq_id=? AND cs_id=0 AND "+prefix+"_mode=? ORDER BY "+prefix+"_name,"+prefix+"_id",hq,mode));}return result;}
|
||||
private List<Map<String,Object>> pending(long customer){return db.queryForList("SELECT gdc_gen_fieldname AS token,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngcssrv' AND gdc_obj_id=? AND gdc_context='PENDING' ORDER BY gdc_id",customer);}
|
||||
Map<String,Object> view(LegacyUser u,long customer){access(u,customer);return db.readTransaction(()->{var services=assignments(customer,false);var types=assignments(customer,true);return fields("services",services.stream().map(Assignment::id).toList(),"types",types.stream().map(Assignment::id).toList(),"serviceOptions",choices(u,false),"typeOptions",choices(u,true),"revision",revision(List.of(services,types)),"operations",pending(customer).stream().map(r->{var p=plan(text(r,"gdc_content"));return fields("token",text(r,"token"),"canResume",p.user()==u.userId()&&p.headquarters()==u.headquartersId());}).toList());});}
|
||||
private void validate(List<Long> requested,List<Assignment> before,List<Map<String,Object>> choices){if(requested==null||requested.size()>500||requested.stream().anyMatch(i->i==null||i<=0)||new HashSet<>(requested).size()!=requested.size())throw bad("Ungültige oder doppelte Servicezuordnung.");var allowed=new HashSet<>(before.stream().map(Assignment::id).toList());choices.forEach(c->allowed.add(id(c,"id")));if(!allowed.containsAll(requested))throw bad("Eine Servicezuordnung ist nicht freigegeben.");}
|
||||
Map<String,Object> save(LegacyUser u,long customer,Save s){access(u,customer);if(s==null)throw bad("Serviceauswahl fehlt.");token(s.requestId());support.transactional("customer","genericdatacontainer");
|
||||
db.writeTransaction(()->{one(db.lockRows("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",customer,u.headquartersId()));var existing=operation(customer,s.requestId());if(!existing.isEmpty()){var p=plan(text(one(existing),"gdc_content"));if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId()||!p.request().equals(s))throw bad("Diese Vorgangskennung gehört zu anderen Daten.");return null;}
|
||||
if(!pending(customer).isEmpty())throw bad("Bitte zuerst den ausstehenden Speichervorgang abschließen.");var beforeServices=assignments(customer,false);var beforeTypes=assignments(customer,true);unchanged(List.of(beforeServices,beforeTypes),s.revision());validate(s.services(),beforeServices,choices(u,false));validate(s.types(),beforeTypes,choices(u,true));
|
||||
String encoded=support.encode(new Plan(u.userId(),u.headquartersId(),customer,s,beforeServices,beforeTypes));if(encoded.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Servicezuordnungen für einen Vorgang.");db.insert("genericdatacontainer",fields("gdc_obj_type","ngcssrv","gdc_obj_id",customer,"gdc_gen_fieldname",s.requestId(),"gdc_content",encoded,"gdc_context","PENDING"));return null;
|
||||
});return resume(u,customer,s.requestId());
|
||||
}
|
||||
private List<Map<String,Object>> operation(long customer,String token){return db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='ngcssrv' AND gdc_obj_id=? AND gdc_gen_fieldname=?",customer,token);}
|
||||
private Plan plan(String json){try{return support.json.readValue(json,Plan.class);}catch(Exception e){throw bad("Der gespeicherte Zuordnungsvorgang ist nicht lesbar.");}}
|
||||
Map<String,Object> resume(LegacyUser u,long customer,String token){access(u,customer);token(token);support.transactional("customer","genericdatacontainer");var row=one(operation(customer,token));var p=plan(text(row,"gdc_content"));if(p.user()!=u.userId()||p.headquarters()!=u.headquartersId()||p.customer()!=customer)throw bad("Nur der ursprüngliche Bearbeiter kann diesen Vorgang fortsetzen.");if(text(row,"gdc_context").equals("DONE"))return fields("state","DONE","token",token);
|
||||
try {return db.customerServiceWriteTransaction(()->{access(u,customer);var current=one(operation(customer,token));if(text(current,"gdc_context").equals("DONE"))return fields("state","DONE","token",token);
|
||||
var services=assignments(customer,false);var types=assignments(customer,true);
|
||||
validate(p.request().services(),p.beforeServices(),choices(u,false));validate(p.request().types(),p.beforeTypes(),choices(u,true));
|
||||
if(!recoverable(services,p.beforeServices(),p.request().services())||!recoverable(types,p.beforeTypes(),p.request().types()))throw bad("Die Zuordnungen wurden außerhalb dieses Vorgangs geändert. Bitte den Konflikt prüfen.");
|
||||
replace(customer,false,services,p.request().services());replace(customer,true,types,p.request().types());
|
||||
support.audit(u,0,customer,0,36,"Servicezuordnungen geändert · Preistypen "+p.request().services().size()+" · Leistungstypen "+p.request().types().size(),token);
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_obj_type='ngcssrv' AND gdc_obj_id=? AND gdc_gen_fieldname=?",customer,token);return fields("state","DONE","token",token);
|
||||
});}catch(org.springframework.dao.DataAccessException|org.springframework.web.server.ResponseStatusException e){return fields("state","PENDING","token",token,"message","Der Zuordnungsvorgang ist gesichert, aber noch nicht vollständig abgeschlossen. Bitte erneut prüfen. "+(e instanceof org.springframework.web.server.ResponseStatusException r?r.getReason():"Die Datenbank war während des Schreibens nicht verfügbar."));}
|
||||
}
|
||||
static List<Assignment> desired(List<Long> ids){var result=new ArrayList<Assignment>();for(int i=0;i<ids.size();i++)result.add(new Assignment(ids.get(i),i));return result;}
|
||||
static boolean recoverable(List<Assignment> current,List<Assignment> before,List<Long> desired){var after=desired(desired);return current.equals(before)||current.size()<=after.size()&¤t.equals(after.subList(0,current.size()));}
|
||||
private void replace(long customer,boolean types,List<Assignment> current,List<Long> desired){var after=desired(desired);if(current.equals(after))return;String table=types?"customerservicetype":"customerservice",key=types?"srvt_id":"srv_id",sort=types?"cssrvt_sort":"cssrv_sort";
|
||||
db.update("DELETE FROM "+table+" WHERE cs_id=?",customer);for(var a:after)db.update("INSERT INTO "+table+" (cs_id,"+key+","+sort+") VALUES (?,?,?)",customer,a.id(),a.sort());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
@RestController @RequestMapping("/api/evaluations")
|
||||
class EvaluationController {
|
||||
final EvaluationService service;final NewsletterTemplate templates;final NewsletterDispatch dispatch;
|
||||
EvaluationController(EvaluationService service,NewsletterTemplate templates,AppointmentMailTransport transport,DatabaseService db){this.service=service;this.templates=templates;this.dispatch=new NewsletterDispatch(db,service,templates,transport);}
|
||||
@PreDestroy void shutdown(){dispatch.shutdown();}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication a){return service.options(user(a));}
|
||||
@GetMapping("/lookup") List<Map<String,Object>> lookup(Authentication a,@RequestParam long headquarters,@RequestParam String kind,@RequestParam(defaultValue="")String q){return service.lookup(user(a),headquarters,kind,q);}
|
||||
@GetMapping("/schema") Map<String,Object> schema(Authentication a,@RequestParam long headquarters){return service.schema(user(a),headquarters);}
|
||||
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody MetafieldRecords.Query q){return service.query(user(a),q);}
|
||||
@GetMapping("/{id}") Map<String,Object> get(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.get(user(a),headquarters,id);}
|
||||
@PostMapping Map<String,Object> create(Authentication a,@RequestBody MetafieldRecords.Save s){return service.save(user(a),null,s);}
|
||||
@PutMapping("/{id}") Map<String,Object> save(Authentication a,@PathVariable long id,@RequestBody MetafieldRecords.Save s){return service.save(user(a),id,s);}
|
||||
record Revision(String revision){}
|
||||
@DeleteMapping("/{id}") void remove(Authentication a,@PathVariable long id,@RequestParam long headquarters,@RequestBody Revision r){service.remove(user(a),headquarters,id,r.revision());}
|
||||
@GetMapping("/{id}/history") List<Map<String,Object>> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.history(user(a),headquarters,id);}
|
||||
@GetMapping("/{id}/preview") Map<String,Object> preview(Authentication a,@PathVariable long id,@RequestParam long headquarters){var data=service.rendering(user(a),headquarters,id);return fields("html",NewsletterDispatch.previewHtml(templates.render(data)),"revision",data.get("revision"));}
|
||||
@GetMapping("/mail/options") Map<String,Object> mailOptions(Authentication a){return dispatch.options(user(a));}
|
||||
@PostMapping("/{id}/mail/preview") Map<String,Object> mailPreview(Authentication a,@PathVariable long id,@RequestBody NewsletterDispatch.Draft d){return dispatch.preview(user(a),id,d);}
|
||||
@PostMapping("/mail/{token}/send") Map<String,Object> mailSend(Authentication a,@PathVariable String token){return dispatch.send(user(a),token);}
|
||||
@GetMapping("/mail/{token}") Map<String,Object> mailStatus(Authentication a,@PathVariable String token){return dispatch.status(user(a),token);}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import java.time.*;
|
||||
import java.time.format.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CrmScope.marks;
|
||||
|
||||
/** Category 15, matching the PHP Bewertung form and its persisted value formats. */
|
||||
@Service
|
||||
class EvaluationService extends MetafieldRecords {
|
||||
static final DateTimeFormatter DATE=DateTimeFormatter.ofPattern("dd.MM.uuuu").withResolverStyle(ResolverStyle.STRICT);
|
||||
EvaluationService(DatabaseService db,FormAdministration forms,MessageAdministration messages){super(db,forms,messages,15);}
|
||||
void access(LegacyUser u){u.require(0);u.require(8);}
|
||||
Map<String,Object> options(LegacyUser u){return fields("headquarters",headquarters(u),"currentHeadquarters",u.headquartersId());}
|
||||
List<Map<String,Object>> lookup(LegacyUser u,long hq,String kind,String q){checked(u,hq);if(q==null)q="";if(q.length()>100)throw bad("Suchbegriff ist zu lang.");String search="%"+q.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%";
|
||||
return switch(kind){
|
||||
case "customer" -> db.queryForList("SELECT cs.cs_id AS id,cs.cs_eid AS value,CONCAT_WS(' · ',cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2) AS label FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND (cs.cs_eid LIKE ? OR cmp.cmp_comp LIKE ?) ORDER BY cs.cs_eid LIMIT 100",hq,search,search);
|
||||
case "courier" -> {u.require(1);yield db.queryForList("SELECT cr.cr_id AS id,cr.cr_eid AS value,CONCAT_WS(' · ',cr.cr_eid,cmp.cmp_comp) AS label FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE cr.hq_id=? AND (cr.cr_eid LIKE ? OR cmp.cmp_comp LIKE ?) ORDER BY cr.cr_eid LIMIT 100",hq,search,search);}
|
||||
case "vehicle" -> {u.require(1);yield db.queryForList("SELECT cv.crvh_id AS id,cv.crvh_sid AS value,cv.crvh_sid AS label FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid LIKE ? ORDER BY cv.crvh_sid LIMIT 100",hq,search);}
|
||||
case "vehicleType" -> db.queryForList("SELECT mt_sort AS id,mt_sort AS value,mt_value AS label FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort");
|
||||
case "group" -> {var hqs=headquarters(u).stream().map(r->id(r,"id")).toList();var args=new ArrayList<Object>(List.of(mandator(db,u)));args.addAll(hqs);args.add(search);yield db.queryForList("SELECT grp_id AS id,grp_id AS value,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=0 OR hq_id IN ("+marks(hqs.size())+")) AND grp_name LIKE ? ORDER BY grp_name LIMIT 100",args.toArray());}
|
||||
case "branch" -> db.queryForList("SELECT br_id AS id,br_id AS value,br_name AS label FROM branch WHERE br_name LIKE ? ORDER BY br_name LIMIT 100",search);
|
||||
default -> throw bad("Unbekannte Bewertungsauswahl.");
|
||||
};
|
||||
}
|
||||
String validateValue(LegacyUser u,long hq,Map<String,Object> def,String value){String type=text(def,"type"),v=value.strip();if(v.isEmpty())return "";switch(type){
|
||||
case "date_begin","date_end","date" -> {parseDate(v);return v;}
|
||||
case "checkboxes_hq" -> {if(!v.matches("[0-9]+(,[0-9]+)*"))throw bad("Ungültige Niederlassungsauswahl.");var allowed=headquarters(u).stream().map(r->Long.toString(id(r,"id"))).toList();var requested=Arrays.stream(v.split(",")).distinct().toList();if(!allowed.containsAll(requested))throw bad("Niederlassung nicht freigegeben.");return String.join(",",requested);}
|
||||
case "button:cs:cs_eid" -> {one(db.queryForList("SELECT cs_id FROM customer WHERE hq_id=? AND cs_eid=?",hq,v));return v;}
|
||||
case "button:cr:cr_eid" -> {u.require(1);one(db.queryForList("SELECT cr_id FROM courier WHERE hq_id=? AND cr_eid=?",hq,v));return v;}
|
||||
case "select:crvh:crvh_sid" -> {u.require(1);if(db.queryForList("SELECT cv.crvh_id FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid=?",hq,v).isEmpty())throw bad("Fahrzeug nicht gefunden.");return v;}
|
||||
case "select:vht" -> {one(db.queryForList("SELECT mt_sort FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",number(v)));return v;}
|
||||
case "select:grp" -> {var hqs=headquarters(u).stream().map(r->id(r,"id")).toList();var args=new ArrayList<Object>(List.of(number(v),mandator(db,u)));args.addAll(hqs);one(db.queryForList("SELECT grp_id FROM groups WHERE grp_id=? AND md_id=? AND (hq_id=0 OR hq_id IN ("+marks(hqs.size())+"))",args.toArray()));return v;}
|
||||
case "select:br" -> {one(db.queryForList("SELECT br_id FROM branch WHERE br_id=?",number(v)));return v;}
|
||||
default -> throw bad(text(def,"label")+": Nicht unterstützter Bewertungsfeldtyp.");
|
||||
}}
|
||||
static long number(String v){try{long n=Long.parseLong(v);if(n<0)throw new NumberFormatException();return n;}catch(NumberFormatException e){throw bad("Ungültiger Auswahlwert.");}}
|
||||
static LocalDate parseDate(String v){try{return LocalDate.parse(v,DATE);}catch(DateTimeParseException e){throw bad("Ungültiges Datum (TT.MM.JJJJ).");}}
|
||||
void validateValues(List<Map<String,Object>> defs,Map<Long,String> values){LocalDate begin=null,end=null;for(var d:defs){String v=values.get(id(d,"id"));if(v==null||v.isBlank())continue;if(text(d,"type").equals("date_begin"))begin=parseDate(v);if(text(d,"type").equals("date_end"))end=parseDate(v);}if(begin!=null&&end!=null&&end.isBefore(begin))throw bad("Das Enddatum liegt vor dem Beginn.");}
|
||||
@Override Map<String,Object> rendering(LegacyUser u,long hq,long object){var result=super.rendering(u,hq,object);var data=new LinkedHashMap<>((Map<String,Object>)result.get("data"));var values=new LinkedHashMap<>((Map<Long,String>)data.get("values"));var defs=(List<Map<String,Object>>)result.get("definitions");for(var def:defs){long field=id(def,"id");String type=text(def,"type"),v=values.getOrDefault(field,"");if(v.isBlank())continue;if(type.equals("checkboxes_hq")){var names=headquarters(u).stream().collect(java.util.stream.Collectors.toMap(r->Long.toString(id(r,"id")),r->text(r,"label")));values.put(field,Arrays.stream(v.split(",")).map(x->names.getOrDefault(x,x)).collect(java.util.stream.Collectors.joining(", ")));}}
|
||||
data.put("values",values);result.put("data",data);return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import java.util.*;
|
||||
import java.time.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Global PHP courierfilter catalog. Durable operations recover MyISAM changes after connection loss. */
|
||||
@Service
|
||||
class FilterAdministration {
|
||||
static final String COLUMNS="crf_id,crf_type,crf_short,crf_text,crf_status,vht_id,crf_sort,crf_longtext";
|
||||
static final String PREFIX="ng_filter_";
|
||||
final DatabaseService db;final ObjectMapper json;final String log;
|
||||
FilterAdministration(DatabaseService db,ObjectMapper json,@Value("${app.history-schema:phoenix_log}")String history){this.db=db;this.json=json;if(!history.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");log="`"+history+"`.log";}
|
||||
record Save(String requestId,String revision,int type,String code,String name,int status,int sort,String description){}
|
||||
record Delete(String requestId,String revision){}
|
||||
record Operation(String token,String kind,long id,long user,long hq,String at,String beforeRevision,Map<String,Object> before,Map<String,Object> after,boolean cleanup){}
|
||||
void readAccess(LegacyUser u){u.require(0);}void writeAccess(LegacyUser u){readAccess(u);u.require(32);}
|
||||
List<Map<String,Object>> rows(){return db.queryForList("SELECT "+COLUMNS+" FROM courierfilter ORDER BY crf_sort,crf_short,crf_id");}
|
||||
Map<String,Object> options(LegacyUser u){readAccess(u);var all=rows();return fields("manage",u.hasRight(32),"items",all.stream().map(r->fields("id",id(r,"crf_id"),"type",id(r,"crf_type"),"code",text(r,"crf_short"),"name",text(r,"crf_text"),"status",id(r,"crf_status"),"sort",id(r,"crf_sort"),"description",text(r,"crf_longtext"),"revision",revision(r))).toList(),"operations",u.hasRight(32)?db.queryForList("SELECT gdc_id AS id,gdc_obj_id AS filterId,SUBSTRING(gdc_gen_fieldname,11) AS token,gdc_context AS state FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 100"):List.of());}
|
||||
Map<String,Object> row(long id){return one(db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_id=?",id));}
|
||||
static String token(String s){try{return UUID.fromString(s).toString();}catch(Exception e){throw bad("Gültige Vorgangskennung fehlt.");}}
|
||||
void globalLock(){if(db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id").isEmpty())throw bad("Keine Niederlassung für die Verwaltungssperre vorhanden.");}
|
||||
Map<String,Object> save(LegacyUser u,Long id,Save s){writeAccess(u);if(s==null)throw bad("Filterdaten fehlen.");String token=token(s.requestId());String code=required(s.code(),4,"Kürzel"),name=required(s.name(),50,"Bezeichnung"),description=Objects.requireNonNullElse(s.description(),"");if(!code.matches("[A-Za-z0-9_+-]{1,4}"))throw bad("Kürzel: 1 bis 4 Buchstaben, Ziffern, _, + oder -.");if(s.type()<0||s.type()>1||s.status()<(id==null?1:0)||s.status()>2||s.sort()<-999999||s.sort()>9999999||description.getBytes(StandardCharsets.UTF_8).length>30000)throw bad("Ungültige Filterart, Pflichtauswahl, Sortiernummer oder zu langer Langtext (max. 30000 UTF-8-Bytes).");
|
||||
var after=fields("crf_type",s.type(),"crf_short",code,"crf_text",name,"crf_status",s.status(),"vht_id",0,"crf_sort",s.sort(),"crf_longtext",description);
|
||||
String kind=id==null?"CREATE":"UPDATE";
|
||||
db.writeTransaction(()->{globalLock();var existing=operationRow(token,false);if(existing!=null){Operation op=decode(existing);if(op.user()!=u.userId()||op.id()!=(id==null?0:id)||!op.kind().equals(kind)||!sameDesired(op.after(),after))throw bad("Vorgangskennung wurde bereits für andere Filterdaten verwendet.");return null;}
|
||||
rejectPending(id,code);Map<String,Object> before=id==null?Map.of():row(id);if(id!=null){unchanged(before,s.revision());if(!text(before,"crf_short").equals(code)||!text(before,"crf_text").equals(name)||id(before,"crf_type")!=s.type()||id(before,"crf_status")!=s.status())throw bad("Bei bestehenden Filtern können Langtext und Sortiernummer geändert werden. Kürzel und Klassifizierung bleiben erhalten.");after.put("vht_id",id(before,"vht_id"));}
|
||||
else {if(!db.queryForList("SELECT crf_id FROM courierfilter WHERE crf_short=? OR crf_text=?",code,name).isEmpty())throw bad("Kürzel oder Bezeichnung existiert bereits.");if(reserved().stream().anyMatch(v->v.equalsIgnoreCase(code)))throw bad("Das Kürzel ist für eine laufende Löschbereinigung gesperrt.");}
|
||||
persist(new Operation(token,kind,id==null?0:id,u.userId(),u.headquartersId(),LocalDateTime.now().toString(),s.revision(),before,after,false));return null;});return resume(u,token);
|
||||
}
|
||||
void rejectPending(Long id,String code){for(var r:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='crf' AND LEFT(gdc_gen_fieldname,10)='ng_filter_' AND gdc_context='PENDING'")){Operation op=decode(r);String reservedCode=text(op.kind().equals("CREATE")?op.after():op.before(),"crf_short");if((id!=null&&op.id()==id)||reservedCode.equalsIgnoreCase(code))throw bad("Für diesen Filter besteht bereits ein gesicherter Vorgang. Bitte diesen zuerst fortsetzen.");}}
|
||||
static boolean sameDesired(Map<String,Object> a,Map<String,Object> b){for(String k:List.of("crf_type","crf_short","crf_text","crf_status","crf_sort","crf_longtext"))if(!Objects.toString(a.get(k),"").equals(Objects.toString(b.get(k),"")))return false;return true;}
|
||||
Map<String,Object> remove(LegacyUser u,long id,Delete d){writeAccess(u);if(d==null)throw bad("Vorgangsdaten fehlen.");String token=token(d.requestId());db.writeTransaction(()->{globalLock();var existing=operationRow(token,false);if(existing!=null){Operation op=decode(existing);if(op.user()!=u.userId()||op.id()!=id||!op.kind().equals("DELETE"))throw bad("Vorgangskennung bereits verwendet.");return null;}var before=row(id);rejectPending(id,text(before,"crf_short"));unchanged(before,d.revision());String code=text(before,"crf_short");if(code.isBlank()||code.contains(","))throw bad("Ungültiges Kürzel im Bestand.");boolean cleanup=db.queryForList("SELECT crf_id FROM courierfilter WHERE crf_short=? AND crf_id<>?",code,id).isEmpty();persist(new Operation(token,"DELETE",id,u.userId(),u.headquartersId(),LocalDateTime.now().toString(),d.revision(),before,Map.of(),cleanup));return null;});return resume(u,token);}
|
||||
Set<String> reserved(){var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key='FILTER_TO_BE_DELETED' AND hq_id=0 AND emp_id=0 AND md_id=0");if(rows.size()>1)throw bad("Mehrdeutige Warteschlange für Filterbereinigung.");return rows.isEmpty()?new LinkedHashSet<>():new LinkedHashSet<>(Arrays.stream(text(rows.getFirst(),"par_value").split(",")).filter(v->!v.isBlank()).toList());}
|
||||
void reserve(String code){var codes=reserved();codes.add(code);String value=","+String.join(",",codes)+",";if(value.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Warteschlange für Filterbereinigung ist voll.");var rows=db.queryForList("SELECT par_id FROM parameter WHERE par_key='FILTER_TO_BE_DELETED' AND hq_id=0 AND emp_id=0 AND md_id=0");if(rows.isEmpty())db.insert("parameter",fields("par_key","FILTER_TO_BE_DELETED","md_id",0,"hq_id",0,"emp_id",0,"par_value",value,"par_text",""));else db.update("UPDATE parameter SET par_value=? WHERE par_id=?",value,id(rows.getFirst(),"par_id"));}
|
||||
void persist(Operation op){try{String content=json.writeValueAsString(op);if(content.getBytes(StandardCharsets.UTF_8).length>65000)throw bad("Änderung ist für das Bestandsprotokoll zu groß. Langtext bitte verkürzen.");db.insert("genericdatacontainer",fields("gdc_obj_type","crf","gdc_obj_id",op.id(),"gdc_gen_fieldname",PREFIX+op.token(),"gdc_content",content,"gdc_context","PENDING"));}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);}}
|
||||
Operation decode(Map<String,Object> row){try{return json.readValue(text(row,"gdc_content"),Operation.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw bad("Gespeicherter Filtervorgang ist beschädigt.");}}
|
||||
Map<String,Object> operationRow(String token,boolean lock){String sql="SELECT gdc_id,gdc_obj_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='crf' AND gdc_gen_fieldname=?";var rows=lock?db.lockRows(sql,PREFIX+token):db.queryForList(sql,PREFIX+token);if(rows.size()>1)throw bad("Doppelte Vorgangskennung im Bestand.");return rows.isEmpty()?null:rows.getFirst();}
|
||||
Map<String,Object> resume(LegacyUser u,String token){writeAccess(u);token(token);var saved=operationRow(token,false);if(saved==null)throw bad("Filtervorgang nicht gefunden.");try{return db.writeTransaction(()->{globalLock();var state=operationRow(token,true);if(text(state,"gdc_context").equals("DONE"))return fields("token",token,"state","DONE","message","Filtervorgang abgeschlossen.");Operation op=decode(state);execute(op);db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(state,"gdc_id"));return fields("token",token,"state","DONE","message",op.kind().equals("DELETE")?"Filter entfernt. Tour- und Dauerauftragsvorlagen werden über die PHP-Bereinigungswarteschlange nachgeführt.":"Filter gespeichert.");});}catch(RuntimeException e){return fields("token",token,"state","PENDING","message","Der Vorgang ist gesichert, aber noch nicht vollständig abgeschlossen. Bitte Liste und Vorgangsstatus prüfen und den gespeicherten Vorgang fortsetzen. Keinen zweiten Vorgang anlegen."+(e instanceof org.springframework.web.server.ResponseStatusException r&&r.getReason()!=null?" "+r.getReason():""));}}
|
||||
void execute(Operation op){long filterId=op.id();if(op.kind().equals("CREATE")){var rows=db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_short=?",text(op.after(),"crf_short"));if(rows.isEmpty()){db.update("INSERT INTO courierfilter (crf_type,crf_short,crf_text,crf_status,vht_id,crf_sort,crf_longtext) SELECT ?,?,?,?,0,?,? WHERE NOT EXISTS (SELECT 1 FROM courierfilter WHERE crf_short=? OR crf_text=?)",id(op.after(),"crf_type"),text(op.after(),"crf_short"),text(op.after(),"crf_text"),id(op.after(),"crf_status"),id(op.after(),"crf_sort"),text(op.after(),"crf_longtext"),text(op.after(),"crf_short"),text(op.after(),"crf_text"));var created=one(db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_short=?",text(op.after(),"crf_short")));if(!sameDesired(created,op.after()))throw bad("Filterkürzel inzwischen anderweitig belegt.");filterId=id(created,"crf_id");}else {if(rows.size()!=1||!sameDesired(rows.getFirst(),op.after()))throw bad("Filterkürzel inzwischen anderweitig belegt.");filterId=id(rows.getFirst(),"crf_id");}}
|
||||
else if(op.kind().equals("UPDATE")){var current=row(op.id());if(!sameDesired(current,op.after())){unchanged(current,op.beforeRevision());var args=new ArrayList<Object>(List.of(text(op.after(),"crf_longtext"),id(op.after(),"crf_sort")));args.addAll(compareArgs(op.before()));if(db.update("UPDATE courierfilter SET crf_longtext=?,crf_sort=? WHERE "+compareClause(),args.toArray())!=1)throw bad("Filter wurde zwischenzeitlich geändert.");}}
|
||||
else if(op.kind().equals("DELETE")){var current=db.queryForList("SELECT "+COLUMNS+" FROM courierfilter WHERE crf_id=?",op.id());if(!current.isEmpty()){unchanged(one(current),op.beforeRevision());if(db.update("DELETE FROM courierfilter WHERE "+compareClause(),compareArgs(op.before()).toArray())!=1)throw bad("Filter wurde zwischenzeitlich geändert.");}if(op.cleanup()){reserve(text(op.before(),"crf_short"));String needle=","+text(op.before(),"crf_short")+",";
|
||||
db.update("UPDATE customer c JOIN company p ON p.cmp_id=c.cmp_id SET c.cs_filter=CASE WHEN REPLACE(c.cs_filter,?,',')=',' THEN '' ELSE REPLACE(c.cs_filter,?,',') END,c.cs_modify=c.cs_modify WHERE p.cmp_archived=0 AND LOCATE(?,c.cs_filter)>0",needle,needle,needle);
|
||||
db.update("UPDATE courier c JOIN company p ON p.cmp_id=c.cmp_id SET c.cr_filter=CASE WHEN REPLACE(c.cr_filter,?,',')=',' THEN '' ELSE REPLACE(c.cr_filter,?,',') END,c.cr_modify=c.cr_modify WHERE p.cmp_archived=0 AND LOCATE(?,c.cr_filter)>0",needle,needle,needle);
|
||||
db.update("UPDATE couriervehicle SET crvh_filter=CASE WHEN REPLACE(crvh_filter,?,',')=',' THEN '' ELSE REPLACE(crvh_filter,?,',') END,crvh_modify=crvh_modify WHERE LOCATE(?,crvh_filter)>0",needle,needle,needle);}}
|
||||
else throw bad("Unbekannter Filtervorgang.");
|
||||
String description="crf_id="+filterId+"|OP="+op.kind()+"|NG_FILTER_REQUEST="+op.token();int operation=op.kind().equals("CREATE")?168:op.kind().equals("DELETE")?169:text(op.before(),"crf_longtext").equals(text(op.after(),"crf_longtext"))?171:170;
|
||||
if(db.queryForList("SELECT log_id FROM "+log+" WHERE logo_id=? AND logo_description=? LIMIT 1",operation,description).isEmpty())db.update("INSERT INTO "+log+" (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,0,?,0,'',0,0,0,0,?,?)",operation,op.hq(),op.user(),description,LocalDateTime.parse(op.at()));
|
||||
}
|
||||
static String compareClause(){return "crf_id=? AND crf_type=? AND BINARY crf_short=BINARY ? AND BINARY crf_text=BINARY ? AND crf_status=? AND vht_id=? AND crf_sort=? AND BINARY crf_longtext=BINARY ?";}
|
||||
static List<Object> compareArgs(Map<String,Object> r){return List.of(id(r,"crf_id"),id(r,"crf_type"),text(r,"crf_short"),text(r,"crf_text"),id(r,"crf_status"),id(r,"vht_id"),id(r,"crf_sort"),text(r,"crf_longtext"));}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController @RequestMapping("/api/administration/filters")
|
||||
class FilterController {
|
||||
final FilterAdministration service;
|
||||
FilterController(FilterAdministration service){this.service=service;}
|
||||
@GetMapping Map<String,Object> options(Authentication a){return service.options(user(a));}
|
||||
@PostMapping Map<String,Object> create(Authentication a,@RequestBody FilterAdministration.Save s){return service.save(user(a),null,s);}
|
||||
@PutMapping("/{id}") Map<String,Object> save(Authentication a,@PathVariable long id,@RequestBody FilterAdministration.Save s){return service.save(user(a),id,s);}
|
||||
@DeleteMapping("/{id}") Map<String,Object> remove(Authentication a,@PathVariable long id,@RequestBody FilterAdministration.Delete s){return service.remove(user(a),id,s);}
|
||||
@PostMapping("/operations/{token}/resume") Map<String,Object> resume(Authentication a,@PathVariable String token){return service.resume(user(a),token);}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Explicit allow-listed metadata operations, corresponding to metafield_editor/template.php. */
|
||||
@Service
|
||||
class FormAdministration {
|
||||
final DatabaseService db;
|
||||
FormAdministration(DatabaseService db){this.db=db;}
|
||||
record CategorySave(String revision,String mnemonic,String description,long template,List<Long> groups){}
|
||||
record FieldSave(String revision,String name,String type){}
|
||||
record TemplateSave(String revision,String name,String content,List<Long> groups){}
|
||||
record BindingSave(String revision,List<Long> keys){}
|
||||
static final String CATEGORY="SELECT mtfc_id,mtfc_mnemonic,mtfc_description,mtfc_group,mtft_id FROM metafieldcategory";
|
||||
static final String KEY="SELECT mtfk_id,mtfk_name,mtfk_type FROM metafieldkey";
|
||||
static final String TEMPLATE="SELECT mtft_id,mtfc_id,mtft_name,mtft_group,mtft_content FROM metafieldtemplate";
|
||||
static final String BINDING="SELECT ck.mtfck_id AS id,ck.mtfk_id AS keyId,ck.mtfck_sort AS sort,ck.mtfck_history AS history,k.mtfk_name AS name,k.mtfk_type AS type FROM metafieldcategorykey ck JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id=? AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id";
|
||||
List<Map<String,Object>> groups(LegacyUser u){return db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name",mandator(db,u),u.headquartersId());}
|
||||
Map<String,Object> category(Map<String,Object> r){return fields("id",id(r,"mtfc_id"),"mnemonic",text(r,"mtfc_mnemonic"),"description",text(r,"mtfc_description"),"template",id(r,"mtft_id"),"groups",GroupAdministration.memberships(text(r,"mtfc_group")),"revision",revision(r));}
|
||||
Map<String,Object> key(Map<String,Object> r){return fields("id",id(r,"mtfk_id"),"name",text(r,"mtfk_name"),"type",text(r,"mtfk_type"),"revision",revision(r));}
|
||||
Map<String,Object> template(Map<String,Object> r,boolean content){var m=fields("id",id(r,"mtft_id"),"name",text(r,"mtft_name"),"groups",GroupAdministration.memberships(text(r,"mtft_group")),"revision",revision(r));if(content)m.put("content",text(r,"mtft_content"));return m;}
|
||||
Map<String,Object> catalog(LegacyUser u){u.require(15);return db.readTransaction(()->fields("categories",db.queryForList(CATEGORY+" ORDER BY mtfc_description,mtfc_id").stream().map(this::category).toList(),"fields",db.queryForList(KEY+" ORDER BY mtfk_name,mtfk_id").stream().map(this::key).toList(),"templates",db.queryForList(TEMPLATE+" ORDER BY mtft_name,mtft_id").stream().map(r->template(r,false)).toList(),"groups",groups(u),"headquartersId",u.headquartersId(),"types",LegacyFormTypes.MAPPED.stream().sorted().toList()));}
|
||||
Map<String,Object> template(LegacyUser u,long id){u.require(15);return template(one(db.queryForList(TEMPLATE+" WHERE mtft_id=?",id)),true);}
|
||||
private String groupsValue(LegacyUser u,List<Long> requested,String current){if(requested==null||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Ungültige Gruppenauswahl.");var allowed=groups(u).stream().map(r->id(r,"id")).toList();var old=GroupAdministration.memberships(current);for(long g:requested)if(!allowed.contains(g)&&!old.contains(g))throw bad("Gruppe nicht freigegeben.");var next=new TreeSet<Long>(requested);for(long g:old)if(!allowed.contains(g))next.add(g);String value=MessageAdministration.comma(next);if(value.length()>255)throw bad("Zu viele Gruppen.");return value;}
|
||||
Map<String,Object> saveCategory(LegacyUser u,long cid,CategorySave s){u.require(15);if(s==null)throw bad("Formulardaten fehlen.");String mnemonic=required(s.mnemonic(),30,"Kürzel"),description=required(s.description(),1000,"Bezeichnung");if(s.template()<0)throw bad("Ungültige Vorlage.");return db.writeTransaction(()->{
|
||||
// These definitions are global. Lock the complete small metadata index to serialize names across branches.
|
||||
var all=db.lockRows(CATEGORY+" ORDER BY mtfc_id");var before=cid==0?Map.<String,Object>of():one(all.stream().filter(r->id(r,"mtfc_id")==cid).toList());if(cid>0)unchanged(before,s.revision());
|
||||
if(!db.queryForList("SELECT mtfc_id FROM metafieldcategory WHERE mtfc_id<>? AND ((?=1 AND mtfc_mnemonic=?) OR (?=1 AND mtfc_description=?))",cid,cid==0||!mnemonic.equals(text(before,"mtfc_mnemonic"))?1:0,mnemonic,cid==0||!description.equals(text(before,"mtfc_description"))?1:0,description).isEmpty())throw bad("Kürzel oder Bezeichnung ist bereits vergeben.");if(s.template()>0)one(db.queryForList("SELECT mtft_id FROM metafieldtemplate WHERE mtft_id=?",s.template()));String memberships=groupsValue(u,s.groups(),text(before,"mtfc_group"));
|
||||
long saved=cid;if(cid==0)saved=db.insert("metafieldcategory",fields("mtfc_mnemonic",mnemonic,"mtfc_description",description,"mtfc_group",memberships,"mtft_id",s.template()));else db.update("UPDATE metafieldcategory SET mtfc_mnemonic=?,mtfc_description=?,mtfc_group=?,mtft_id=? WHERE mtfc_id=?",mnemonic,description,memberships,s.template(),cid);return fields("id",saved);
|
||||
});}
|
||||
Map<String,Object> saveField(LegacyUser u,long fid,FieldSave s){u.require(15);if(s==null)throw bad("Felddaten fehlen.");String name=required(s.name(),100,"Feldname"),type=required(s.type(),50,"Feldtyp");return db.writeTransaction(()->{
|
||||
var all=db.lockRows(KEY+" ORDER BY mtfk_id");var before=fid==0?Map.<String,Object>of():one(all.stream().filter(r->id(r,"mtfk_id")==fid).toList());if(fid>0)unchanged(before,s.revision());
|
||||
if((fid==0||!name.equals(text(before,"mtfk_name")))&&!db.queryForList("SELECT mtfk_id FROM metafieldkey WHERE mtfk_id<>? AND mtfk_name=?",fid,name).isEmpty())throw bad("Dieser Feldname ist bereits vergeben.");
|
||||
boolean known=all.stream().anyMatch(r->text(r,"mtfk_type").equals(type));
|
||||
if(type.startsWith("ref:")&&!(fid>0&&type.equals(text(before,"mtfk_type"))&&!type.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))){if(!type.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))throw bad("Kombinierte Felder als ref:ID:ID angeben.");var references=Arrays.stream(type.substring(4).split(":")).map(Long::parseLong).toList();var available=all.stream().map(r->id(r,"mtfk_id")).toList();if(!available.containsAll(references)||references.contains(fid))throw bad("Ungültige Feldreferenz.");var graph=new HashMap<Long,List<Long>>();for(var r:all){String t=text(r,"mtfk_type");if(t.matches("ref:[1-9][0-9]{0,9}(?::[1-9][0-9]{0,9})*"))graph.put(id(r,"mtfk_id"),Arrays.stream(t.substring(4).split(":")).map(Long::parseLong).toList());}if(fid>0){graph.put(fid,references);checkCycle(fid,graph,new HashSet<>(),new HashSet<>());}known=true;}
|
||||
if(!known&&!LegacyFormTypes.MAPPED.contains(type)&&!Set.of("varchar","text","checkbox","date","date_begin","date_end","datetime","time","birthdate","number","author","co2cs","image").contains(type))throw bad("Bitte einen vorhandenen Feldtyp auswählen.");
|
||||
long saved=fid;if(fid==0)saved=db.insert("metafieldkey",fields("mtfk_name",name,"mtfk_type",type));else db.update("UPDATE metafieldkey SET mtfk_name=?,mtfk_type=? WHERE mtfk_id=?",name,type,fid);return fields("id",saved);
|
||||
});}
|
||||
private static void checkCycle(long id,Map<Long,List<Long>> graph,Set<Long> path,Set<Long> done){if(done.contains(id))return;if(!path.add(id))throw bad("Zyklische Feldreferenzen sind nicht erlaubt.");for(long child:graph.getOrDefault(id,List.of()))checkCycle(child,graph,path,done);path.remove(id);done.add(id);}
|
||||
Map<String,Object> saveTemplate(LegacyUser u,long tid,TemplateSave s){u.require(15);if(s==null)throw bad("Vorlagendaten fehlen.");String name=required(s.name(),50,"Vorlagenname");String content=s.content()==null?"":s.content();if(content.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Die Vorlage überschreitet 65.535 Bytes.");return db.writeTransaction(()->{
|
||||
var all=db.lockRows(TEMPLATE+" ORDER BY mtft_id");var before=tid==0?Map.<String,Object>of():one(all.stream().filter(r->id(r,"mtft_id")==tid).toList());if(tid>0)unchanged(before,s.revision());if((tid==0||!name.equals(text(before,"mtft_name")))&&!db.queryForList("SELECT mtft_id FROM metafieldtemplate WHERE mtft_id<>? AND mtft_name=?",tid,name).isEmpty())throw bad("Vorlagenname ist bereits vergeben.");String memberships=groupsValue(u,s.groups(),text(before,"mtft_group"));long saved=tid;if(tid==0)saved=db.insert("metafieldtemplate",fields("mtft_name",name,"mtft_group",memberships,"mtft_content",content));else db.update("UPDATE metafieldtemplate SET mtft_name=?,mtft_group=?,mtft_content=? WHERE mtft_id=?",name,memberships,content,tid);return fields("id",saved);
|
||||
});}
|
||||
long fieldHeadquarters(LegacyUser u,long category){String specific=parameter("SYSTEM_FORM_SINGLE_HQ_"+category),general=parameter("SYSTEM_FORM_SINGLE_HQ");return (!specific.isBlank()&&!specific.equals("0"))||(!general.isBlank()&&!general.equals("0"))?u.headquartersId():0;}
|
||||
private String parameter(String key){var p=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=0 AND emp_id=0 AND par_key=?",key);if(p.size()>1)throw bad("Mehrdeutige Formularzuordnung.");return p.isEmpty()?"":text(p.getFirst(),"par_value");}
|
||||
Map<String,Object> bindings(LegacyUser u,long category){u.require(15);var form=one(db.queryForList(CATEGORY+" WHERE mtfc_id=?",category));long hq=fieldHeadquarters(u,category);var rows=db.queryForList(BINDING,category,hq);return fields("category",category(form),"headquarters",hq,"fields",rows,"revision",revision(Arrays.asList(hq,rows)));}
|
||||
Map<String,Object> saveBindings(LegacyUser u,long category,BindingSave s){u.require(15);if(s==null||s.keys()==null||s.keys().size()>1000||s.keys().stream().anyMatch(k->k==null||k<=0)||new HashSet<>(s.keys()).size()!=s.keys().size())throw bad("Feldzuordnung ist ungültig oder enthält doppelte Felder.");return db.writeTransaction(()->{
|
||||
one(db.lockRows(CATEGORY+" WHERE mtfc_id=?",category));long hq=fieldHeadquarters(u,category);db.lockRows("SELECT mtfck_id FROM metafieldcategorykey WHERE mtfc_id=? AND hq_id=? ORDER BY mtfck_id",category,hq);var current=db.queryForList(BINDING,category,hq);unchanged(Arrays.asList(hq,current),s.revision());
|
||||
if(!s.keys().containsAll(current.stream().map(r->id(r,"keyId")).toList()))throw bad("Bestehende Felder dürfen nicht entfernt werden; ihre gespeicherten Werte bleiben erhalten.");if(current.stream().map(r->id(r,"keyId")).distinct().count()!=current.size())throw bad("Doppelte Felder im Bestand. Bitte die Zuordnung zuerst bereinigen.");var keys=db.queryForList("SELECT mtfk_id FROM metafieldkey").stream().map(r->id(r,"mtfk_id")).toList();if(!keys.containsAll(s.keys()))throw bad("Unbekanntes Feld.");
|
||||
for(int i=0;i<s.keys().size();i++){long key=s.keys().get(i);var existing=current.stream().filter(r->id(r,"keyId")==key).findFirst();if(existing.isPresent())db.update("UPDATE metafieldcategorykey SET mtfck_sort=? WHERE mtfck_id=? AND hq_id=? AND mtfc_id=?",i+1,id(existing.get(),"id"),hq,category);else db.insert("metafieldcategorykey",fields("hq_id",hq,"mtfc_id",category,"mtfk_id",key,"mtfck_sort",i+1,"mtfck_history",0));}return fields("saved",true);
|
||||
});}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController
|
||||
@RequestMapping("/api/forms")
|
||||
class FormController {
|
||||
final FormAdministration service;
|
||||
FormController(FormAdministration service){this.service=service;}
|
||||
@GetMapping Map<String,Object> catalog(Authentication a){return service.catalog(user(a));}
|
||||
@PostMapping("/categories") Map<String,Object> category(Authentication a,@RequestBody FormAdministration.CategorySave s){return service.saveCategory(user(a),0,s);}
|
||||
@PutMapping("/categories/{id}") Map<String,Object> category(Authentication a,@PathVariable long id,@RequestBody FormAdministration.CategorySave s){return service.saveCategory(user(a),id,s);}
|
||||
@PostMapping("/fields") Map<String,Object> field(Authentication a,@RequestBody FormAdministration.FieldSave s){return service.saveField(user(a),0,s);}
|
||||
@PutMapping("/fields/{id}") Map<String,Object> field(Authentication a,@PathVariable long id,@RequestBody FormAdministration.FieldSave s){return service.saveField(user(a),id,s);}
|
||||
@GetMapping("/templates/{id}") Map<String,Object> template(Authentication a,@PathVariable long id){return service.template(user(a),id);}
|
||||
@PostMapping("/templates") Map<String,Object> template(Authentication a,@RequestBody FormAdministration.TemplateSave s){return service.saveTemplate(user(a),0,s);}
|
||||
@PutMapping("/templates/{id}") Map<String,Object> template(Authentication a,@PathVariable long id,@RequestBody FormAdministration.TemplateSave s){return service.saveTemplate(user(a),id,s);}
|
||||
@GetMapping("/categories/{id}/fields") Map<String,Object> bindings(Authentication a,@PathVariable long id){return service.bindings(user(a),id);}
|
||||
@PutMapping("/categories/{id}/fields") Map<String,Object> bindings(Authentication a,@PathVariable long id,@RequestBody FormAdministration.BindingSave s){return service.saveBindings(user(a),id,s);}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
@Service
|
||||
public class GroupAdministration {
|
||||
private final DatabaseService db;
|
||||
public GroupAdministration(DatabaseService db){this.db=db;}
|
||||
record GroupSave(String revision,String name,boolean shared) {}
|
||||
record MembershipSave(String revision,boolean member) {}
|
||||
// Every identifier is a code constant. No client-controlled table or column names.
|
||||
enum Kind {
|
||||
CUSTOMER("Kunden","customer","cs","JOIN company c ON c.cmp_id=o.cmp_id","CONCAT_WS(' ',o.cs_eid,c.cmp_comp,c.cmp_comp2)","o.hq_id",0),
|
||||
COURIER("Transporteure","courier","cr","JOIN user u ON u.usr_id=o.usr_id","CONCAT_WS(' ',o.cr_eid,u.usr_firstname,u.usr_name)","o.hq_id",1),
|
||||
VEHICLE("Fahrzeuge","couriervehicle","crvh","JOIN courier cr ON cr.cr_id=o.cr_id","CONCAT_WS(' ',o.crvh_sid,o.crvh_vh_sign,o.crvh_vh_name)","cr.hq_id",1),
|
||||
EMPLOYEE("Mitarbeiter","employee","emp","JOIN user u ON u.usr_id=o.usr_id","CONCAT_WS(' ',u.usr_firstname,u.usr_name)","u.hq_id",3),
|
||||
ARTICLE("Artikel","article","at","","CONCAT_WS(' ',o.at_eid,o.at_name)","o.hq_id",14),
|
||||
SERVICE("Services","service","srv","","o.srv_name","o.hq_id",2),
|
||||
SERVICE_TYPE("Servicetypen","servicetype","srvt","","o.srvt_name","o.hq_id",2),
|
||||
GROUP("Gruppen","groups","grp","","o.grp_name","o.hq_id",25),
|
||||
BRANCH("Branchen","branch","br","","CONCAT_WS(' ',o.br_key,o.br_name)",null,10),
|
||||
FORM("Formulare","metafieldcategory","mtfc","","o.mtfc_mnemonic",null,15),
|
||||
TEMPLATE("Vorlagen","metafieldtemplate","mtft","","o.mtft_name",null,15);
|
||||
final String label,table,prefix,joins,labelSql,hq;final int right;
|
||||
Kind(String label,String table,String prefix,String joins,String labelSql,String hq,int right){this.label=label;this.table=table;this.prefix=prefix;this.joins=joins;this.labelSql=labelSql;this.hq=hq;this.right=right;}
|
||||
String id(){return prefix+"_id";}String group(){return prefix+"_group";}
|
||||
}
|
||||
void access(LegacyUser user){user.require(8);user.require(25);}
|
||||
public List<Map<String,Object>> headquarters(LegacyUser user) {
|
||||
access(user);
|
||||
return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id WHERE m.md_id=?"+(user.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY label",user.hasRight(10)?new Object[]{mandator(db,user)}:new Object[]{mandator(db,user),user.headquartersId()});
|
||||
}
|
||||
LegacyUser scoped(LegacyUser user,Long headquarters) {
|
||||
access(user);
|
||||
if(headquarters==null||headquarters==user.headquartersId())return user;
|
||||
user.require(10);
|
||||
if(headquarters(user).stream().noneMatch(h->id(h,"id")==headquarters))throw new AccessDeniedException("Niederlassung gehört nicht zum freigegebenen Mandanten.");
|
||||
return new LegacyUser(user.userId(),user.employeeId(),headquarters,user.username(),user.rights(),user.originalHeadquartersId());
|
||||
}
|
||||
void rejectDeletion(long id) {
|
||||
if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='nggrpdel' AND gdc_obj_id=? AND gdc_context='PENDING'",id).isEmpty())throw bad("Die Gruppe wird gerade gelöscht. Bitte den gespeicherten Löschvorgang fortsetzen.");
|
||||
}
|
||||
private List<Map<String,Object>> rows(LegacyUser user) {
|
||||
return db.queryForList("SELECT grp_id,md_id,hq_id,grp_name,grp_readonly,grp_system,grp_group FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name,grp_id",mandator(db,user),user.headquartersId());
|
||||
}
|
||||
public List<Map<String,Object>> list(LegacyUser user) {
|
||||
access(user);return db.readTransaction(()->rows(user).stream().map(r->view(user,r)).toList());
|
||||
}
|
||||
private Map<String,Object> view(LegacyUser user,Map<String,Object> r) {
|
||||
return fields("id",id(r,"grp_id"),"name",text(r,"grp_name"),"headquartersId",id(r,"hq_id"),"shared",id(r,"hq_id")==0,"readOnly",id(r,"grp_readonly")!=0 || id(r,"grp_system")!=0 || (id(r,"hq_id")==0&&!user.hasRight(10)),"revision",revision(r));
|
||||
}
|
||||
Map<String,Object> group(LegacyUser user,long groupId,boolean write) {
|
||||
String sql="SELECT grp_id,md_id,hq_id,grp_name,grp_readonly,grp_system,grp_group FROM groups WHERE grp_id=? AND md_id=? AND (hq_id=? OR hq_id=0)";
|
||||
Object[] args={groupId,mandator(db,user),user.headquartersId()};
|
||||
var r=one(write?db.lockRows(sql,args):db.queryForList(sql,args));
|
||||
if(write && (id(r,"grp_readonly")!=0 || id(r,"grp_system")!=0))throw bad("Diese Systemgruppe ist gegen Änderungen geschützt.");
|
||||
if(write && id(r,"hq_id")==0)user.require(10);
|
||||
return r;
|
||||
}
|
||||
public Map<String,Object> save(LegacyUser user,long groupId,GroupSave request) {
|
||||
access(user);String name=required(request.name(),100,"Gruppenbezeichnung");
|
||||
if(request.shared())user.require(10);
|
||||
return db.writeTransaction(()->{
|
||||
long md=mandator(db,user);
|
||||
// Serialize names within the mandator, not merely within one branch.
|
||||
one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",md));
|
||||
if(groupId>0){rejectDeletion(groupId);unchanged(group(user,groupId,true),request.revision());}
|
||||
if(!db.queryForList("SELECT grp_id FROM groups WHERE md_id=? AND grp_name=? AND grp_id<>?",md,name,groupId).isEmpty())throw bad("Diese Gruppenbezeichnung existiert bereits im Mandanten.");
|
||||
long id=groupId;
|
||||
if(id==0)id=db.insert("groups",fields("md_id",md,"hq_id",request.shared()?0:user.headquartersId(),"grp_name",name));
|
||||
else db.update("UPDATE groups SET grp_name=?,hq_id=? WHERE grp_id=? AND md_id=?",name,request.shared()?0:user.headquartersId(),id,md);
|
||||
return view(user,group(user,id,false));
|
||||
});
|
||||
}
|
||||
public Map<String,Object> members(LegacyUser user,long groupId,Kind kind,String search,boolean onlyMembers) {
|
||||
access(user);user.require(kind.right);var g=group(user,groupId,false);
|
||||
var args=new ArrayList<Object>();String where=scope(user,kind,args);
|
||||
if(onlyMembers){where+=" AND o."+kind.group()+" LIKE ?";args.add("%,"+groupId+",%");}
|
||||
if(search!=null&&!search.isBlank()){if(search.length()>100)throw bad("Suchtext zu lang.");where+=" AND "+kind.labelSql+" LIKE ?";args.add("%"+search.strip()+"%");}
|
||||
String sql="SELECT o."+kind.id()+" AS id,"+kind.labelSql+" AS label,o."+kind.group()+" AS memberships FROM "+kind.table+" o "+kind.joins+" WHERE "+where+" ORDER BY label,id LIMIT 501";
|
||||
var data=db.queryForList(sql,args.toArray());
|
||||
boolean truncated=data.size()>500;
|
||||
return fields("group",view(user,g),"kinds",Arrays.stream(Kind.values()).filter(k->user.hasRight(k.right)&&(k.hq!=null||user.hasRight(10))).map(k->fields("id",k.name(),"label",k.label)).toList(),"truncated",truncated,
|
||||
"items",data.stream().limit(500).map(r->fields("id",id(r,"id"),"label",text(r,"label"),"member",memberships(text(r,"memberships")).contains(groupId),"revision",revision(Arrays.asList(id(r,"id"),value(r,"memberships"))))).toList());
|
||||
}
|
||||
private String scope(LegacyUser user,Kind kind,List<Object> args) {
|
||||
if(kind.hq==null){user.require(10);return "1=1";}
|
||||
if(kind==Kind.GROUP){args.add(mandator(db,user));args.add(user.headquartersId());return "o.md_id=? AND (o.hq_id=? OR o.hq_id=0)";}
|
||||
args.add(user.headquartersId());return kind.hq+"=?";
|
||||
}
|
||||
public void membership(LegacyUser user,long groupId,Kind kind,long objectId,MembershipSave request) {
|
||||
access(user);user.require(kind.right);
|
||||
db.writeTransaction(()->{
|
||||
long md=mandator(db,user);one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",md));if(request.member())rejectDeletion(groupId);group(user,groupId,true);
|
||||
var args=new ArrayList<Object>();String where=scope(user,kind,args);args.add(objectId);
|
||||
var object=one(db.queryForList("SELECT o."+kind.id()+" AS id,o."+kind.group()+" AS memberships FROM "+kind.table+" o "+kind.joins+" WHERE "+where+" AND o."+kind.id()+"=?",args.toArray()));
|
||||
unchanged(Arrays.asList(objectId,value(object,"memberships")),request.revision());
|
||||
if(kind==Kind.GROUP) {
|
||||
group(user,objectId,true);
|
||||
if(request.member())checkCycle(md,objectId,groupId);
|
||||
}
|
||||
var ids=memberships(text(object,"memberships"));if(request.member())ids.add(groupId);else ids.remove(groupId);
|
||||
String newValue=ids.isEmpty()?"":","+String.join(",",ids.stream().map(String::valueOf).toList())+",";
|
||||
if(newValue.length()>255)throw bad("Zu viele Gruppenzuordnungen für diesen Datensatz.");
|
||||
if(db.update("UPDATE "+kind.table+" SET "+kind.group()+"=? WHERE "+kind.id()+"=? AND "+kind.group()+" <=> ?",newValue,objectId,value(object,"memberships"))!=1)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Gruppenzuordnung wurde zwischenzeitlich geändert.");
|
||||
return null;
|
||||
});
|
||||
}
|
||||
static LinkedHashSet<Long> memberships(String value) {
|
||||
var ids=new LinkedHashSet<Long>();for(String part:value.split(","))if(part.strip().matches("[0-9]{1,10}")){long id=Long.parseLong(part.strip());if(id>0)ids.add(id);}return ids;
|
||||
}
|
||||
private void checkCycle(long md,long child,long parent) {
|
||||
var all=db.queryForList("SELECT grp_id,grp_group FROM groups WHERE md_id=?",md);
|
||||
var graph=new HashMap<Long,Set<Long>>();for(var r:all)graph.put(id(r,"grp_id"),memberships(text(r,"grp_group")));
|
||||
var pending=new ArrayDeque<Long>();var visited=new HashSet<Long>();pending.add(parent);
|
||||
while(!pending.isEmpty()){long current=pending.removeFirst();if(current==child)throw bad("Eine Gruppe darf weder direkt noch indirekt Mitglied von sich selbst werden.");if(visited.add(current))pending.addAll(graph.getOrDefault(current,Set.of()));}
|
||||
}
|
||||
public void remove(LegacyUser user,long groupId,String expected) {
|
||||
access(user);
|
||||
db.writeTransaction(()->{
|
||||
long md=mandator(db,user);one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",md));unchanged(group(user,groupId,true),expected);
|
||||
// Require explicit removal of memberships first. This avoids silently changing rights,
|
||||
// and makes deletion atomic even when legacy branch records use MyISAM.
|
||||
for(Kind k:Kind.values())if(!db.queryForList("SELECT "+k.id()+" FROM "+k.table+" WHERE "+k.group()+" LIKE ? LIMIT 1","%,"+groupId+",%").isEmpty())throw bad("Die Gruppe hat noch Mitglieder. Bitte die Zuordnungen vor dem Löschen entfernen.");
|
||||
db.update("DELETE FROM groups WHERE grp_id=? AND md_id=?",groupId,md);return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** A durable, reviewed deletion plan: branch memberships can live in MyISAM. */
|
||||
@Service
|
||||
class GroupDeletion {
|
||||
final DatabaseService db; final GroupAdministration groups; final ObjectMapper json; final BusinessSupport support;
|
||||
GroupDeletion(DatabaseService db,GroupAdministration groups,ObjectMapper json,BusinessSupport support){this.db=db;this.groups=groups;this.json=json;this.support=support;}
|
||||
record Member(String kind,long id,String before,String after) {}
|
||||
record Plan(long id,long mandator,long headquarters,long user,String groupRevision,String name,List<Member> members) {}
|
||||
record Delete(String requestId,String revision) {}
|
||||
static final String PREFIX="remove_";
|
||||
|
||||
List<Member> members(LegacyUser user,long groupId) {
|
||||
var result=new ArrayList<Member>();var allowed=groups.headquarters(user).stream().map(h->id(h,"id")).toList();
|
||||
for(var k:GroupAdministration.Kind.values()) {
|
||||
String alias="member_"+k.prefix;String hq=k.hq==null?"0":k.hq.replace("o.",alias+".");
|
||||
String joins=k==GroupAdministration.Kind.VEHICLE?"JOIN courier cr ON cr.cr_id="+alias+".cr_id":k==GroupAdministration.Kind.EMPLOYEE?"JOIN user u ON u.usr_id="+alias+".usr_id":"";
|
||||
var rows=db.queryForList("SELECT "+alias+"."+k.id()+" AS id,"+alias+"."+k.group()+" AS memberships,"+hq+" AS headquarters"+(k==GroupAdministration.Kind.GROUP?","+alias+".md_id,"+alias+".grp_readonly,"+alias+".grp_system":"")+" FROM "+k.table+" "+alias+" "+joins+" WHERE FIND_IN_SET(CAST(? AS CHAR),TRIM(BOTH ',' FROM COALESCE("+alias+"."+k.group()+",'')))>0 ORDER BY "+alias+"."+k.id(),groupId);
|
||||
for(var r:rows) {
|
||||
user.require(k.right);if(k.hq==null)user.require(10);
|
||||
if(k.hq!=null&&id(r,"headquarters")!=0&&!allowed.contains(id(r,"headquarters")))throw bad("Die Gruppe besitzt Zuordnungen außerhalb Ihrer freigegebenen Niederlassungen.");
|
||||
if(k==GroupAdministration.Kind.GROUP&&(id(r,"md_id")!=mandator(db,user)||id(r,"grp_readonly")!=0||id(r,"grp_system")!=0))throw bad("Eine geschützte oder fremde Gruppe verweist auf diese Gruppe.");
|
||||
String before=text(r,"memberships");result.add(new Member(k.name(),id(r,"id"),before,without(before,groupId)));
|
||||
}
|
||||
}
|
||||
// Message groups use an unpadded comma list, unlike ordinary memberships.
|
||||
for(var r:db.queryForList("SELECT msggrp_id AS id,msggrp_group AS memberships,hq_id AS headquarters FROM messagegroup WHERE FIND_IN_SET(CAST(? AS CHAR),TRIM(BOTH ',' FROM COALESCE(msggrp_group,'')))>0 ORDER BY msggrp_id",groupId)) {
|
||||
user.require(52);if(!allowed.contains(id(r,"headquarters")))throw bad("Eine Nachrichtengruppe einer nicht freigegebenen Niederlassung verweist auf die Gruppe.");
|
||||
String before=text(r,"memberships");result.add(new Member("MESSAGE_GROUP",id(r,"id"),before,without(before,groupId)));
|
||||
}
|
||||
if(result.size()>20000)throw bad("Mehr als 20000 Zuordnungen. Bitte die Gruppe zunächst über die Mitgliederpflege verkleinern.");
|
||||
return result;
|
||||
}
|
||||
static String without(String before,long id) {
|
||||
var parts=new ArrayList<>(Arrays.asList(before.split(",",-1)));
|
||||
parts.removeIf(p->p.strip().equals(Long.toString(id)));
|
||||
String after=String.join(",",parts);return after.replace(",","").isBlank()?"":after;
|
||||
}
|
||||
Plan plan(LegacyUser user,long id) {
|
||||
groups.access(user);var group=groups.group(user,id,true);
|
||||
return new Plan(id,mandator(db,user),user.headquartersId(),user.userId(),revision(group),text(group,"grp_name"),members(user,id));
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser user,long id) {
|
||||
groups.access(user);return db.readTransaction(()->{
|
||||
// Preview must not request a database write lock.
|
||||
var group=groups.group(user,id,false);
|
||||
if(id(group,"grp_readonly")!=0||id(group,"grp_system")!=0)throw bad("Diese Systemgruppe ist gegen Änderungen geschützt.");
|
||||
if(id(group,"hq_id")==0)user.require(10);
|
||||
var p=new Plan(id,mandator(db,user),user.headquartersId(),user.userId(),revision(group),text(group,"grp_name"),members(user,id));
|
||||
var counts=new LinkedHashMap<String,Integer>();p.members().forEach(m->counts.merge(m.kind(),1,Integer::sum));
|
||||
return fields("revision",revision(p),"total",p.members().size(),"counts",counts.entrySet().stream().map(e->fields("label",e.getKey().equals("MESSAGE_GROUP")?"Nachrichtengruppen":GroupAdministration.Kind.valueOf(e.getKey()).label,"count",e.getValue())).toList());
|
||||
});
|
||||
}
|
||||
Map<String,Object> operation(String token,boolean lock) {
|
||||
String sql="SELECT gdc_id,gdc_obj_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='nggrpdel' AND gdc_gen_fieldname=?";
|
||||
var rows=lock?db.lockRows(sql,PREFIX+token):db.queryForList(sql,PREFIX+token);
|
||||
return rows.isEmpty()?null:one(rows);
|
||||
}
|
||||
Plan decode(Map<String,Object> row) {try{return json.readValue(text(row,"gdc_content"),Plan.class);}catch(Exception e){throw bad("Löschvorgang ist nicht lesbar.");}}
|
||||
void owner(LegacyUser user,Plan p) {
|
||||
groups.access(user);if(p.user()!=user.userId()||p.mandator()!=mandator(db,user)||p.headquarters()!=user.headquartersId())throw bad("Der Löschvorgang gehört zu einem anderen Benutzer oder Niederlassungsbereich.");
|
||||
}
|
||||
List<Map<String,Object>> pending(LegacyUser user) {
|
||||
groups.access(user);return db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='nggrpdel' AND gdc_context='PENDING' ORDER BY gdc_id").stream().filter(r->{var p=decode(r);return p.user()==user.userId()&&p.headquarters()==user.headquartersId()&&p.mandator()==mandator(db,user);}).<Map<String,Object>>map(r->fields("token",text(r,"gdc_gen_fieldname").substring(PREFIX.length()),"name",decode(r).name())).toList();
|
||||
}
|
||||
Map<String,Object> remove(LegacyUser user,long id,Delete command) {
|
||||
groups.access(user);if(command==null)throw bad("Löschvorgang fehlt.");String token=BusinessSupport.token(command.requestId());
|
||||
support.transactional("groups","genericdatacontainer","mandator");
|
||||
db.writeTransaction(()->{
|
||||
one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",mandator(db,user)));
|
||||
var existing=operation(token,false);
|
||||
if(existing!=null){var p=decode(existing);owner(user,p);if(p.id()!=id||!revision(p).equals(command.revision()))throw bad("Vorgangskennung wurde bereits für eine andere Löschung verwendet.");return null;}
|
||||
groups.rejectDeletion(id);var p=plan(user,id);unchanged(p,command.revision());
|
||||
String encoded=support.encode(p);if(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>65000)throw bad("Zu viele Zuordnungen für einen Löschvorgang. Bitte zunächst Mitglieder entfernen.");
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","nggrpdel","gdc_obj_id",id,"gdc_gen_fieldname",PREFIX+token,"gdc_content",encoded,"gdc_context","PENDING"));return null;
|
||||
});return resume(user,token);
|
||||
}
|
||||
Map<String,Object> resume(LegacyUser user,String token) {
|
||||
groups.access(user);BusinessSupport.token(token);var saved=operation(token,false);if(saved==null)throw bad("Löschvorgang nicht gefunden.");owner(user,decode(saved));
|
||||
try {return db.groupDeleteTransaction(()->{
|
||||
one(db.lockRows("SELECT md_id FROM mandator WHERE md_id=?",mandator(db,user)));var state=operation(token,true);var p=decode(state);owner(user,p);
|
||||
if(text(state,"gdc_context").equals("DONE"))return fields("state","DONE","token",token);
|
||||
unchanged(groups.group(user,p.id(),true),p.groupRevision());
|
||||
var current=members(user,p.id());
|
||||
// Only reviewed memberships may be removed. New PHP additions require manual resolution.
|
||||
for(var m:current)if(p.members().stream().noneMatch(old->old.kind().equals(m.kind())&&old.id()==m.id()&&old.before().equals(m.before())))throw bad("Zuordnungen wurden seit der Vorschau geändert. Bitte diese Änderung zuerst in der Mitgliederverwaltung klären.");
|
||||
for(var m:current) {
|
||||
String table,key,column;
|
||||
if(m.kind().equals("MESSAGE_GROUP")){table="messagegroup";key="msggrp_id";column="msggrp_group";}else{var kind=GroupAdministration.Kind.valueOf(m.kind());table=kind.table;key=kind.id();column=kind.group();}
|
||||
if(db.update("UPDATE "+table+" SET "+column+"=? WHERE "+key+"=? AND BINARY "+column+" <=> BINARY ?",m.after(),m.id(),m.before())!=1)throw bad("Eine Zuordnung wurde parallel geändert. Bitte den Vorgang erneut prüfen.");
|
||||
if(m.kind().equals("MESSAGE_GROUP"))support.audit(user,0,0,0,203,"MSGGRP_ID="+m.id()+"|MSGGRP_NAME=EMPTY|OP=REMOVE_GROUP|GRP_ID="+p.id(),token+"-"+m.id());
|
||||
}
|
||||
if(!members(user,p.id()).isEmpty())throw bad("Es bestehen inzwischen weitere Zuordnungen. Die Gruppe bleibt erhalten.");
|
||||
db.update("DELETE FROM groups WHERE grp_id=? AND md_id=?",p.id(),p.mandator());
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(state,"gdc_id"));
|
||||
return fields("state","DONE","token",token);
|
||||
});}catch(RuntimeException e){return fields("state","PENDING","token",token,"message","Löschvorgang gespeichert. Bereits entfernte Zuordnungen bleiben entfernt; bitte denselben Vorgang fortsetzen."+(e instanceof org.springframework.web.server.ResponseStatusException r?" "+r.getReason():""));}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.Station;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
|
||||
/** PHP services_func.inc.php: most specific enclosing range and odd/even selection. */
|
||||
final class LegacyAddressTariffs {
|
||||
private LegacyAddressTariffs(){}
|
||||
static long postal(DatabaseService db,long hq,Station station){
|
||||
if(station.billingPostalId()!=null){if(db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_id=?",station.billingPostalId()).isEmpty())throw invalid("Die gewählte Abrechnungs-PLZ ist nicht mehr verfügbar.");return station.billingPostalId();}
|
||||
long mapped=mapping(db,null,hq,station,false);if(mapped>0)return mapped;
|
||||
var rows=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=? AND COALESCE(NULLIF(srvp_country,''),'DE')=?",station.postalCode(),station.country());
|
||||
if(rows.size()>1)throw invalid("Die Abrechnungs-PLZ ist im gewählten Land mehrdeutig.");return rows.isEmpty()?0:id(rows.getFirst(),"srvp_id");
|
||||
}
|
||||
static long area(DatabaseService db,OrderEntryCatalog catalog,long hq,long postal,Station station){
|
||||
long mapped=mapping(db,catalog,hq,station,true);if(mapped>0)return mapped;
|
||||
var rows=db.queryForList("SELECT m.srvpa_id FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE m.srvp_id=? AND a.hq_id=?",postal,hq);
|
||||
if(rows.size()>1)throw invalid("Die Tarifgebietszuordnung der PLZ "+station.postalCode()+" ist mehrdeutig.");return rows.isEmpty()?0:id(rows.getFirst(),"srvpa_id");
|
||||
}
|
||||
static long mapping(DatabaseService db,OrderEntryCatalog catalog,long hq,Station station,boolean area){
|
||||
String digits=station.houseNumber().strip().replaceFirst("[^0-9].*$","");if(digits.isEmpty()||digits.length()>7||station.street().isBlank())return 0;
|
||||
long house=Long.parseLong(digits),lower=0,upper=99999,match=0;
|
||||
String p=area?"srvpaa":"srvpxa",target=area?"srvpa_id":"srvp_id",table=area?"serviceplzareaaddress":"serviceplzaddress";
|
||||
String join=area?" JOIN serviceplzarea t ON t.srvpa_id=m.srvpa_id AND t.hq_id=m.hq_id":" JOIN serviceplz t ON t.srvp_id=m.srvp_id";
|
||||
var rows=db.queryForList("SELECT m."+p+"_id AS id,m."+target+" AS targetId,m."+p+"_hsno_from AS houseFrom,m."+p+"_hsno_to AS houseTo,m."+p+"_mode AS mode FROM "+table+" m JOIN address a ON a.ad_id=m.ad_id"+join+" WHERE m.hq_id=? AND a.ad_zipcode=? AND COALESCE(NULLIF(a.ad_country,''),'DE')=? AND a.ad_street LIKE ? ESCAPE '!' ORDER BY m."+p+"_id",hq,station.postalCode(),station.country(),AddressAdministration.like(station.street()));
|
||||
for(var row:rows){long from=bound(row,"houseFrom",0),to=bound(row,"houseTo",99999),mode=id(row,"mode");if(from>=lower&&to<=upper&&house>=from&&house<=to&&(mode==0||mode==(house%2==0?2:1))){lower=from;upper=to;match=id(row,"targetId");
|
||||
if(area){String raw=catalog.parameter(0,"MASK_AREA_ID_FROM_ADDRESS_"+id(row,"id"));for(String pair:raw.split(";")){String[] v=pair.split(",");if(v.length==2&&v[0].equals(station.houseNumber().replace(" ",""))&&v[1].matches("[0-9]{1,9}")){long special=Long.parseLong(v[1]);if(db.queryForList("SELECT srvpa_id FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",special,hq).isEmpty())throw invalid("Der abweichende Hausnummernbereich ist nicht mehr in dieser Niederlassung verfügbar.");match=special;}}}
|
||||
}}return match;
|
||||
}
|
||||
static long bound(Map<String,Object> row,String key,long fallback){String v=text(row,key);try{return v.isBlank()?fallback:Long.parseLong(v);}catch(NumberFormatException e){return fallback;}}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.math.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.LegacyOrderPricing.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
|
||||
/** ajaxReqKmPrice.php: distance/zone rates, approach/departure, minimum and adoption rounding. */
|
||||
final class LegacyDistancePricing {
|
||||
record Price(BigDecimal base,BigDecimal courier,List<CalculationLine> lines) {}
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final LegacyOrderPricing tariffs;
|
||||
LegacyDistancePricing(OrderEntryCatalog catalog,LegacyOrderPricing tariffs){this.catalog=catalog;this.tariffs=tariffs;}
|
||||
Price calculate(Request request,OrderRoutingService.Result route,long cs,String vehicle,LocalDateTime at,BigDecimal customerDiscount,BigDecimal deduction) {
|
||||
if(route==null)throw invalid("Für diesen Preis muss zunächst die Strecke berechnet werden.");
|
||||
long hq=route.info().pricingHeadquartersId();boolean zone=request.pricing().mode()==PriceMode.ZONE;
|
||||
boolean longhaul=route.info().longhaul();
|
||||
List<CalculationLine> lines=new ArrayList<>();BigDecimal customer=BigDecimal.ZERO,courier=BigDecimal.ZERO;
|
||||
Tariff minimum=new Tariff(BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,false);
|
||||
if(!zone) {
|
||||
Tariff km=tariffs.service(hq,cs,vehicle,"km-Preis",at,customerDiscount);
|
||||
if(!km.found() || km.price().signum()<=0)throw invalid("Für Fahrzeug und Kunde fehlt ein gültiger km-Preis.");
|
||||
BigDecimal distance=route.info().distanceKm();
|
||||
customer=money(km.price().multiply(distance));courier=cr(km,deduction).multiply(distance);
|
||||
lines.add(new CalculationLine("Kilometer",distance,km.price(),customer));
|
||||
minimum=tariffs.service(hq,cs,vehicle,"Mindestpreis",at,customerDiscount);
|
||||
} else {
|
||||
Map<String,String> names=new HashMap<>();
|
||||
for(String entry:catalog.parameter(hq,"PZM_MASK_ZONES").split(";")) {
|
||||
if(entry.isBlank())continue;String[] parts=entry.split(",",2);
|
||||
if(parts.length!=2)throw invalid("Die Zuordnung der PZM-Tarifzonen ist ungültig.");names.put(parts[0].trim(),parts[1].trim());
|
||||
}
|
||||
boolean found=false;
|
||||
for(String group:List.of("zoneDistance","extraApproachDistance","extraDepartureCalculation")) {
|
||||
if(group.equals("extraDepartureCalculation") && longhaul && catalog.enabled(hq,"PZM_ROUNDTRIPKM"))continue;
|
||||
var distances=new LinkedHashMap<String,BigDecimal>();Map<String,String> zoneKeys=new HashMap<>();int unique=0;
|
||||
for(JsonNode part:route.zones().path(group)) {
|
||||
String name=part.path("zone").asText();if(name.isBlank())throw invalid("Die Route enthält eine unbenannte Tarifzone.");
|
||||
if(group.equals("extraDepartureCalculation"))name=name.replace("_edc","_eap");
|
||||
String key=name.contains("_fix")?name+"#"+(unique++):name;
|
||||
distances.merge(key,OrderRoutingService.metric(part,"distance"),BigDecimal::add);zoneKeys.put(key,name);
|
||||
}
|
||||
for(var entry:distances.entrySet()) {
|
||||
String key=zoneKeys.get(entry.getKey()),name=names.getOrDefault(key,key);
|
||||
Tariff tariff=tariffs.service(hq,cs,vehicle,name,at,customerDiscount);
|
||||
BigDecimal rate=tariff.price(),crRate=cr(tariff,deduction);
|
||||
if(longhaul && group.equals("zoneDistance")) {
|
||||
rate=parameter("BWV_"+name.toUpperCase(Locale.ROOT)+"_"+vehicle,rate);
|
||||
BigDecimal rateDeduction=parameter("BWV_Frachtverg_Abschlag_"+vehicle,deduction);
|
||||
crRate=rawDiscount(tariff.price(),rateDeduction);
|
||||
}
|
||||
if(rate.signum()<=0)continue;found=true;
|
||||
if(longhaul && group.equals("extraApproachDistance") && catalog.enabled(hq,"PZM_ROUNDTRIPKM"))rate=rate.multiply(BigDecimal.TWO);
|
||||
BigDecimal quantity=key.contains("_fix")?BigDecimal.ONE:OrderRoutingService.kilometers(entry.getValue());
|
||||
BigDecimal charge=quantity.multiply(rate),crCharge=quantity.multiply(crRate);
|
||||
if(group.equals("zoneDistance") || key.contains("_fix")){charge=money(charge);crCharge=money(crCharge);}
|
||||
customer=customer.add(charge);courier=courier.add(crCharge);
|
||||
String label=(group.equals("extraApproachDistance")?"Anfahrt · ":group.equals("extraDepartureCalculation")?"Abfahrt · ":"")+name.replace("<br>"," ");
|
||||
lines.add(new CalculationLine(label,quantity,rate,money(charge)));
|
||||
String minKey=key.replace("_km","").replace("_fix","").replace("_eap","")+"_min";
|
||||
Tariff candidate=tariffs.service(hq,cs,vehicle,names.getOrDefault(minKey,minKey),at,customerDiscount);
|
||||
if(candidate.price().compareTo(minimum.price())>0)minimum=candidate;
|
||||
}
|
||||
}
|
||||
if(!found)throw invalid("Für die ermittelten PZM-Zonen fehlen gültige Tarife.");
|
||||
}
|
||||
Tariff basic=(!zone && catalog.enabled(hq,"MASK_NO_BASIC_PRICE_FOR_KM_PRICE"))?new Tariff(BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,true)
|
||||
:tariffs.service(hq,cs,vehicle,"Grundpreis",at,customerDiscount);
|
||||
Tariff extra=tariffs.service(hq,cs,vehicle,"Weitere<br>Adressen",at,customerDiscount);
|
||||
BigDecimal stops=BigDecimal.valueOf(request.stops().size()-2);
|
||||
BigDecimal extraPrice=extra.price().multiply(stops);
|
||||
customer=money(customer.add(basic.price()).add(extraPrice));courier=money(courier.add(cr(basic,deduction)).add(cr(extra,deduction).multiply(stops)));
|
||||
if(basic.price().signum()!=0)lines.add(new CalculationLine("Grundpreis",BigDecimal.ONE,basic.price(),basic.price()));
|
||||
if(stops.signum()>0)lines.add(new CalculationLine("Weitere Adressen",stops,extra.price(),money(extraPrice)));
|
||||
if(customer.compareTo(minimum.price())<0) {
|
||||
lines.add(new CalculationLine("Anhebung auf Mindestpreis",BigDecimal.ONE,minimum.price(),minimum.price().subtract(customer)));
|
||||
customer=minimum.price();
|
||||
}
|
||||
// PHP uses the entire customer base when no explicit minimum courier price is defined.
|
||||
BigDecimal crMinimum=minimum.courier().signum()==0?rawDiscount(customer,deduction):minimum.courier();
|
||||
courier=money(courier.max(crMinimum));
|
||||
BigDecimal adopted=(!zone || (catalog.enabled(hq,"PZM_MASK_ROUND") && !catalog.enabled(0,"PZM_MASK_DONT_ROUND_"+cs)))
|
||||
?customer.setScale(0,RoundingMode.CEILING):money(customer);
|
||||
if(adopted.compareTo(customer)!=0)lines.add(new CalculationLine("Aufrundung",BigDecimal.ONE,adopted.subtract(customer),adopted.subtract(customer)));
|
||||
return new Price(money(adopted),courier,List.copyOf(lines));
|
||||
}
|
||||
static String legacyType(Request request,OrderRoutingService.Result route,Price price) {
|
||||
if(request.pricing().mode()==PriceMode.ZONE) {
|
||||
return price.lines().stream().filter(line -> line.name().equals("Anhebung auf Mindestpreis"))
|
||||
.map(line -> "[M] Mindestpreis "+money(line.rate()).toPlainString()).findFirst().orElse("");
|
||||
}
|
||||
BigDecimal kmRate=price.lines().stream().filter(line -> line.name().equals("Kilometer")).map(CalculationLine::rate).findFirst().orElse(BigDecimal.ZERO);
|
||||
BigDecimal basic=price.lines().stream().filter(line -> line.name().equals("Grundpreis")).map(CalculationLine::amount).findFirst().orElse(BigDecimal.ZERO);
|
||||
return route.info().distanceKm().toPlainString()+"|"+kmRate.toPlainString()+"|"+basic.toPlainString();
|
||||
}
|
||||
private static BigDecimal cr(Tariff tariff,BigDecimal deduction){return tariff.courier().signum()==0?rawDiscount(tariff.price(),deduction):tariff.courier();}
|
||||
private static BigDecimal rawDiscount(BigDecimal amount,BigDecimal deduction){return amount.multiply(BigDecimal.valueOf(100).subtract(deduction)).divide(BigDecimal.valueOf(100));}
|
||||
private BigDecimal parameter(String key,BigDecimal fallback) {
|
||||
String value=catalog.parameter(0,key);if(value.isBlank())return fallback;
|
||||
try{return new BigDecimal(value.trim().replace(',','.'));}catch(NumberFormatException e){throw invalid("Der Bestandsparameter "+key+" ist ungültig.");}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package de.stadtbote.votian;
|
||||
import java.util.Set;
|
||||
/** Field type identifiers from include/inc_metafield.inc.php; not executable SQL. */
|
||||
final class LegacyFormTypes {
|
||||
private LegacyFormTypes() {}
|
||||
static final Set<String> MAPPED=Set.of(
|
||||
"db:at:article_barcode",
|
||||
"db:at:article_bundlecode",
|
||||
"db:at:article_bundlequantity",
|
||||
"db:at:article_description",
|
||||
"db:at:article_eid",
|
||||
"db:at:article_match",
|
||||
"db:at:article_name",
|
||||
"db:at:article_packingpieces",
|
||||
"db:at:article_serialnumber",
|
||||
"db:at:article_stock_areaquantity",
|
||||
"db:at:article_stock_itemquantity",
|
||||
"db:at:article_timeunits",
|
||||
"db:cr:bank",
|
||||
"db:cr:bankaccount",
|
||||
"db:cr:banknumber",
|
||||
"db:cr:birthdate",
|
||||
"db:cr:city",
|
||||
"db:cr:company_name",
|
||||
"db:cr:company_name2",
|
||||
"db:cr:courier_eid",
|
||||
"db:cr:email",
|
||||
"db:cr:fax",
|
||||
"db:cr:firstname",
|
||||
"db:cr:housenumber",
|
||||
"db:cr:iban",
|
||||
"db:cr:imei",
|
||||
"db:cr:name",
|
||||
"db:cr:outlay",
|
||||
"db:cr:phone",
|
||||
"db:cr:phone2",
|
||||
"db:cr:phone_pda",
|
||||
"db:cr:postage",
|
||||
"db:cr:staxno",
|
||||
"db:cr:street",
|
||||
"db:cr:swift",
|
||||
"db:cr:taxno",
|
||||
"db:cr:zipcode",
|
||||
"db:cs:bank",
|
||||
"db:cs:bankaccount",
|
||||
"db:cs:banknumber",
|
||||
"db:cs:city",
|
||||
"db:cs:company_name",
|
||||
"db:cs:company_name2",
|
||||
"db:cs:costcenteraddress_city",
|
||||
"db:cs:costcenteraddress_comp",
|
||||
"db:cs:costcenteraddress_email",
|
||||
"db:cs:costcenteraddress_hsno",
|
||||
"db:cs:costcenteraddress_phone",
|
||||
"db:cs:costcenteraddress_street",
|
||||
"db:cs:costcenteraddress_zipcode",
|
||||
"db:cs:discount",
|
||||
"db:cs:eid",
|
||||
"db:cs:email",
|
||||
"db:cs:fax",
|
||||
"db:cs:hash",
|
||||
"db:cs:housenumber",
|
||||
"db:cs:iban",
|
||||
"db:cs:logo",
|
||||
"db:cs:logo_height",
|
||||
"db:cs:logo_width",
|
||||
"db:cs:markup_provision",
|
||||
"db:cs:phone",
|
||||
"db:cs:phone2",
|
||||
"db:cs:postage",
|
||||
"db:cs:provision",
|
||||
"db:cs:stat_businessvolume_01",
|
||||
"db:cs:stat_businessvolume_02",
|
||||
"db:cs:staxno",
|
||||
"db:cs:street",
|
||||
"db:cs:swift",
|
||||
"db:cs:taxno",
|
||||
"db:cs:zipcode",
|
||||
"db:jb:cr_price",
|
||||
"db:jb:cr_sid",
|
||||
"db:jb:cr_subprice",
|
||||
"db:jb:createtime",
|
||||
"db:jb:daytime",
|
||||
"db:jb:export_time",
|
||||
"db:jb:filter_mandatory",
|
||||
"db:jb:filter_optional",
|
||||
"db:jb:finishtime",
|
||||
"db:jb:fixprice",
|
||||
"db:jb:freetext_1",
|
||||
"db:jb:freetext_2",
|
||||
"db:jb:freetext_3",
|
||||
"db:jb:gdc_jb",
|
||||
"db:jb:id",
|
||||
"db:jb:id_parent",
|
||||
"db:jb:invoice_text",
|
||||
"db:jb:is_installation",
|
||||
"db:jb:markup",
|
||||
"db:jb:ordertime",
|
||||
"db:jb:parent_id_fallback_jb_id",
|
||||
"db:jb:postage",
|
||||
"db:jb:sales_tax_rate",
|
||||
"db:jb:service",
|
||||
"db:jb:service_acceptance_protocol_packing_pieces",
|
||||
"db:jb:service_acceptance_protocol_points",
|
||||
"db:jb:service_acceptance_protocol_text",
|
||||
"db:jb:service_photos",
|
||||
"db:jb:service_sign",
|
||||
"db:jb:serviceprice",
|
||||
"db:jb:status",
|
||||
"db:jb:subtotalprice",
|
||||
"db:jb:taketime",
|
||||
"db:jb:timeunits",
|
||||
"db:jb:totalprice",
|
||||
"db:jb:tourdata",
|
||||
"db:jb:type",
|
||||
"db:jb:vehicle_height",
|
||||
"db:jb:vehicle_length",
|
||||
"db:jb:vehicle_planned",
|
||||
"db:jb:vehicle_position",
|
||||
"db:jb:vehicle_real",
|
||||
"db:jb:vehicle_width",
|
||||
"db:jb:warningtime",
|
||||
"db:jb:weight",
|
||||
"db:pt:city",
|
||||
"db:pt:company_name",
|
||||
"db:pt:company_name2",
|
||||
"db:pt:discount",
|
||||
"db:pt:eid",
|
||||
"db:pt:housenumber",
|
||||
"db:pt:street",
|
||||
"db:pt:zipcode",
|
||||
"db:tr:city",
|
||||
"db:tr:commission_number",
|
||||
"db:tr:comp",
|
||||
"db:tr:comp2",
|
||||
"db:tr:country",
|
||||
"db:tr:hsno",
|
||||
"db:tr:jobpayment_exist_payment_difference",
|
||||
"db:tr:jobpayment_payment_method",
|
||||
"db:tr:person",
|
||||
"db:tr:phone",
|
||||
"db:tr:remark",
|
||||
"db:tr:sign",
|
||||
"db:tr:station_no",
|
||||
"db:tr:street",
|
||||
"db:tr:tourarticle_article_barcode",
|
||||
"db:tr:tourarticle_article_bundlecode",
|
||||
"db:tr:tourarticle_article_bundlequantity",
|
||||
"db:tr:tourarticle_article_description",
|
||||
"db:tr:tourarticle_article_eid",
|
||||
"db:tr:tourarticle_article_id",
|
||||
"db:tr:tourarticle_article_match",
|
||||
"db:tr:tourarticle_article_name",
|
||||
"db:tr:tourarticle_article_packingpieces",
|
||||
"db:tr:tourarticle_article_serialnumber",
|
||||
"db:tr:tourarticle_article_stock_areaquantity",
|
||||
"db:tr:tourarticle_article_stock_itemquantity",
|
||||
"db:tr:tourarticle_article_timeunits",
|
||||
"db:tr:tourarticle_calculator_quantity",
|
||||
"db:tr:tourarticle_calculator_servicename",
|
||||
"db:tr:tourarticle_calculator_single_price",
|
||||
"db:tr:tourarticle_calculator_total_price",
|
||||
"db:tr:tourarticle_id",
|
||||
"db:tr:tourarticle_number_of_all_packingpieces",
|
||||
"db:tr:tourarticle_packingpieces",
|
||||
"db:tr:tourarticle_price",
|
||||
"db:tr:tourarticle_quantity",
|
||||
"db:tr:tourarticle_remark",
|
||||
"db:tr:tourarticle_serialno",
|
||||
"db:tr:tourarticle_sort",
|
||||
"db:tr:tourarticle_state",
|
||||
"db:tr:tourarticle_sum_of_volume",
|
||||
"db:tr:tourarticle_sum_of_weight",
|
||||
"db:tr:tourarticle_summated_calculator_value",
|
||||
"db:tr:tourarticle_summated_price",
|
||||
"db:tr:tourarticle_summated_value_of_goods",
|
||||
"db:tr:tourarticle_value_of_goods",
|
||||
"db:tr:ware_from_to",
|
||||
"db:tr:zipcode",
|
||||
"sys:current_date",
|
||||
"sys:current_headquarters_city",
|
||||
"sys:current_headquarters_company_logo",
|
||||
"sys:current_headquarters_company_name",
|
||||
"sys:current_headquarters_company_name2",
|
||||
"sys:current_headquarters_housenumber",
|
||||
"sys:current_headquarters_id",
|
||||
"sys:current_headquarters_mnemonic",
|
||||
"sys:current_headquarters_name",
|
||||
"sys:current_headquarters_street",
|
||||
"sys:current_headquarters_zipcode",
|
||||
"sys:current_user",
|
||||
"sys:empty_field"
|
||||
);
|
||||
}
|
||||
@@ -35,10 +35,10 @@ final class LegacyMapping {
|
||||
}
|
||||
static String join(String a,String b) { return (a+" "+b).trim(); }
|
||||
static Customer customer(Map<String,Object> row) {
|
||||
Customer c=new Customer();c.id=id(row,"cs_id");c.number=text(row,"cs_eid");c.name=text(row,"cmp_comp");
|
||||
Customer c=new Customer();c.costCenterId=nullableId(row,"app_costcenter_id");c.costCenterName=text(row,"app_costcenter_name");c.id=id(row,"cs_id");c.number=text(row,"cs_eid");c.name=text(row,"cmp_comp");
|
||||
c.street=join(text(row,"ad_street"),text(row,"cmp_hsno"));c.postalCode=text(row,"ad_zipcode");c.city=text(row,"ad_city");
|
||||
c.contact=join(text(row,"usr_firstname"),text(row,"usr_name"));c.phone=text(row,"usr_phone");c.email=text(row,"usr_email");
|
||||
String days=text(row,"cs_payment_term");c.paymentDays=days.matches("\\d+")?Integer.valueOf(days):null;
|
||||
c.paymentTermLabel=text(row,"payment_term_label");String days=text(row,"cs_payment_term");c.paymentDays=days.matches("\\d+")?Integer.valueOf(days):null;
|
||||
c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1;
|
||||
return c;
|
||||
}
|
||||
@@ -68,7 +68,9 @@ final class LegacyMapping {
|
||||
static Stop stop(Map<String,Object> row) {
|
||||
Stop s=new Stop();s.id=id(row,"tr_id");s.sequence=(int)id(row,"tr_sort");
|
||||
// Existing VoTiAn tour field is preserved through the explicit warehouse direction mapping.
|
||||
s.type=switch((int)id(row,"tr_ware_from_to")){case 1->StopType.PICKUP;case 2->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;};
|
||||
// job_options.php and get_saved_tour.php: bit 2 = collection, bit 1 = delivery.
|
||||
// The old get_tr_ware_from_to_text helper contains the inverse labels.
|
||||
s.type=switch((int)id(row,"tr_ware_from_to")){case 2->StopType.PICKUP;case 1->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;};
|
||||
s.name=join(text(row,"tr_comp"),text(row,"tr_comp2"));s.street=join(text(row,"ad_street"),text(row,"tr_hsno"));
|
||||
s.postalCode=text(row,"ad_zipcode");s.city=text(row,"ad_city");s.contact=text(row,"tr_person");s.phone=text(row,"tr_phone");s.note=text(row,"tr_remark");
|
||||
s.completedAt=id(row,"tr_status")==1?dateTime(row,"tr_finishtime"):null;s.recipientName=text(row,"tr_signname");return s;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
|
||||
/** Port of services_func.inc.php and job_options.php:1828-1867. All amounts remain decimal. */
|
||||
@Service
|
||||
public class LegacyOrderPricing {
|
||||
private static final BigDecimal ZERO=BigDecimal.ZERO, HUNDRED=new BigDecimal("100");
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final List<MarkupHistory> markupHistory;
|
||||
record MarkupHistory(long customerId, String after, BigDecimal rate) {}
|
||||
record Tariff(BigDecimal price,BigDecimal discount,BigDecimal courier,boolean found) {}
|
||||
public LegacyOrderPricing(DatabaseService db,OrderEntryCatalog catalog) {
|
||||
this.db=db;this.catalog=catalog;
|
||||
try(var input=getClass().getResourceAsStream("/legacy-markup-history.json")) {
|
||||
markupHistory=new ObjectMapper().readValue(input,new TypeReference<List<MarkupHistory>>() {});
|
||||
} catch(Exception e) { throw new IllegalStateException("Die übernommenen Zuschlagsstände fehlen.",e); }
|
||||
}
|
||||
|
||||
Quote calculate(LegacyUser user,Request request,Map<String,Object> payer,LocalDateTime at) {
|
||||
return calculate(user,request,payer,at,null,List.of());
|
||||
}
|
||||
Quote calculate(LegacyUser user,Request request,Map<String,Object> payer,LocalDateTime at,OrderRoutingService.Result routing,List<PhotoChoice> photos) {
|
||||
return calculate(user,request,payer,at,routing,photos,null);
|
||||
}
|
||||
Quote calculate(LegacyUser user,Request request,Map<String,Object> payer,LocalDateTime at,OrderRoutingService.Result routing,List<PhotoChoice> photos,PhotoSet photoSet) {
|
||||
Pricing input=request.pricing();
|
||||
RouteInfo route=routing==null?null:routing.info();
|
||||
long hq=route!=null && (input.mode()==PriceMode.KILOMETER || input.mode()==PriceMode.ZONE)?route.pricingHeadquartersId():user.headquartersId();
|
||||
Map<String,Object> customer=payer;
|
||||
Station pickup=request.stops().getFirst();
|
||||
if(pickup.costCenterId()!=null && input.mode()!=PriceMode.SERVICES) {
|
||||
var origin=catalog.costCenter(user,pickup.costCenterId(),false);
|
||||
if(id(origin,"cs_jbcond")==1) customer=origin;
|
||||
}
|
||||
long cs=id(customer,"cs_id");
|
||||
String vehicle=text(one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",
|
||||
request.vehicleTypeId()),"Bitte einen gültigen Fahrzeugtyp wählen."),"mt_value");
|
||||
String override=catalog.parameter(0,"COSTCENTER_INV_MODE_"+request.payerCostCenterId());
|
||||
int invoiceMode=override.isBlank()?(int)id(customer,"cs_invmode"):Integer.parseInt(override);
|
||||
boolean batch=id(customer,"cs_jobbatch")==1 && request.mode()!=Mode.RETROSPECTIVE;
|
||||
BigDecimal markup=input.markup()!=null?input.markup():fuelMarkup(hq,cs,vehicle,at,customer);
|
||||
if(route!=null && route.longhaul() && input.mode()==PriceMode.ZONE && input.markup()==null) {
|
||||
String value=catalog.parameter(0,"BWV_AUSSERHALB_TSZ");
|
||||
markup=value.isBlank()?ZERO:new BigDecimal(value.trim().replace(',','.'));
|
||||
}
|
||||
BigDecimal courierMarkup=input.courierMarkup()!=null?input.courierMarkup():fuelCourierMarkup(hq,cs,vehicle,at,customer);
|
||||
if(Set.of(2L,3L,5L).contains(hq) && at.isBefore(LocalDateTime.of(2021,11,16,0,0)) && markup.signum()>0) markup=ZERO;
|
||||
BigDecimal discount=input.applyFixedDiscount()?number(customer,"cs_fixprice_discount"):ZERO;
|
||||
BigDecimal deduction=courierDeduction(hq,cs,vehicle,at);
|
||||
List<Component> components=new ArrayList<>();
|
||||
BigDecimal courierBase=ZERO;
|
||||
boolean automatic=input.mode()==PriceMode.KILOMETER || input.mode()==PriceMode.ZONE;
|
||||
var autoPrice=automatic?new LegacyDistancePricing(catalog,this).calculate(request,routing,cs,vehicle,at,number(customer,"cs_discount"),deduction):null;
|
||||
List<CalculationLine> calculation=autoPrice==null?List.of():autoPrice.lines();
|
||||
if(input.mode()==PriceMode.SERVICES) {
|
||||
if(request.services().isEmpty())throw invalid("Bitte mindestens eine Leistungsposition auswählen.");
|
||||
} else if((input.mode()==PriceMode.FIXED || automatic) && !batch) {
|
||||
if(!automatic && (input.fixedPrice()==null || input.fixedPrice().signum()<=0))
|
||||
throw invalid("Ein Festpreis muss größer als 0 sein. Für die Tarifberechnung bitte Tarif wählen.");
|
||||
BigDecimal base=automatic?autoPrice.base():input.fixedPrice();
|
||||
BigDecimal cr=positive(input.courierPrice())?discount(input.courierPrice(),discount):automatic?discount(autoPrice.courier(),discount):discount(discount(base,discount),deduction);
|
||||
courierBase=cr;
|
||||
components.add(new Component(0,"Fixpreis",automatic?LegacyDistancePricing.legacyType(request,routing,autoPrice):"",markup(base,markup),discount,discount(markup(base,markup),discount),
|
||||
discount(base,discount),markup(cr,courierMarkup),cr));
|
||||
} else {
|
||||
// The active PHP saveServiceCosts branch only implements PLZ/area tariffs.
|
||||
if(invoiceMode<1)
|
||||
throw invalid("Für diesen Kunden ist kein PLZ-/Gebietstarif hinterlegt. Bitte einen kalkulierten Festpreis eingeben.");
|
||||
Tariff basic=service(hq,cs,vehicle,"Grundpreis",at,number(customer,"cs_discount"));
|
||||
boolean noBasicMarkup=catalog.enabled(hq,"MASK_NO_MARKUP_ON_VEHICLE_PRICE");
|
||||
components.add(component(0,"Grundpreis",vehicle,basic,noBasicMarkup?ZERO:markup,noBasicMarkup?ZERO:courierMarkup,deduction));
|
||||
int mappedVehicle=mappedVehicle(hq,request.vehicleTypeId());
|
||||
for(int i=1;i<request.stops().size();i++) {
|
||||
Station from=request.stops().get(i-1),to=request.stops().get(i);
|
||||
if(!from.country().equals("DE") || !to.country().equals("DE"))
|
||||
throw invalid("Für eine Auslandsstrecke bitte einen kalkulierten Festpreis eingeben.");
|
||||
Tariff tariff=route(hq,cs,mappedVehicle,invoiceMode,from,to,at,number(customer,"cs_discount"));
|
||||
if(!tariff.found()) throw invalid("Kein gültiger Tarif für Station "+i+" → "+(i+1)+". Bitte die Adressen oder den Festpreis prüfen.");
|
||||
components.add(component(i,"PLZ "+from.postalCode()+" nach PLZ "+to.postalCode(),"",tariff,markup,courierMarkup,deduction));
|
||||
}
|
||||
courierBase=sum(components,Component::courierSubtotal);
|
||||
if(positive(input.courierPrice())) courierBase=input.courierPrice();
|
||||
}
|
||||
if(!request.services().isEmpty()) {
|
||||
var allowed=catalog.services(user,request.payerCostCenterId());
|
||||
for(ServiceItem item:request.services()) {
|
||||
var choice=allowed.stream().filter(s -> s.serviceId()==item.serviceId() && s.typeId()==item.typeId()).findFirst()
|
||||
.orElseThrow(() -> invalid("Die ausgewählte Leistung ist für den Kunden nicht verfügbar."));
|
||||
Tariff tariff=serviceValues(hq,cs,item.serviceId(),item.typeId(),at);
|
||||
if(!tariff.found())tariff=serviceValues(hq,0,item.serviceId(),item.typeId(),at);
|
||||
if(!tariff.found())tariff=serviceValues(0,cs,item.serviceId(),item.typeId(),at);
|
||||
if(!tariff.found())tariff=serviceValues(0,0,item.serviceId(),item.typeId(),at);
|
||||
if(!tariff.found())throw invalid("Für die Leistung fehlt ein gültiger Preis: "+choice.service());
|
||||
BigDecimal unit=placeholder(tariff.price()),cr=placeholder(tariff.courier());
|
||||
if(cr.signum()==0)cr=discount(unit,deduction);
|
||||
var extended=new Tariff(money(unit.multiply(item.quantity())),input.applyFixedDiscount()?discount:tariff.discount(),money(cr.multiply(item.quantity())),true);
|
||||
components.add(component(0,item.quantity().stripTrailingZeros().toPlainString()+" × "+choice.service(),choice.type(),extended,markup,courierMarkup,deduction));
|
||||
}
|
||||
courierBase=sum(components,Component::courierSubtotal);
|
||||
}
|
||||
BigDecimal net=sum(components,Component::net),subtotal=sum(components,Component::subtotal);
|
||||
BigDecimal courierNet=sum(components,Component::courierNet),courierSubtotal=sum(components,Component::courierSubtotal);
|
||||
if((input.mode()==PriceMode.TARIFF || input.mode()==PriceMode.SERVICES) && positive(input.courierPrice())) {
|
||||
courierBase=input.courierPrice();courierSubtotal=input.courierPrice();courierNet=markup(courierSubtotal,courierMarkup);
|
||||
}
|
||||
BigDecimal service=nz(input.servicePrice());
|
||||
BigDecimal serviceDiscount=catalog.enabled(hq,"INV_SERVICEPRICE_DISCOUNT")?discount:ZERO;
|
||||
BigDecimal courierService=positive(input.courierServicePrice())?discount(input.courierServicePrice(),serviceDiscount):discount(service,deduction);
|
||||
if(service.signum()>0 || courierService.signum()>0) {
|
||||
components.add(new Component(0,"Servicepreis","",service,serviceDiscount,discount(service,serviceDiscount),
|
||||
discount(service,serviceDiscount),courierService,courierService));
|
||||
net=net.add(discount(service,serviceDiscount));subtotal=subtotal.add(discount(service,serviceDiscount));
|
||||
courierNet=courierNet.add(courierService);courierSubtotal=courierSubtotal.add(courierService);
|
||||
}
|
||||
BigDecimal automaticService=streetService(hq,request,at);
|
||||
if(automaticService.signum()>0) {
|
||||
components.removeIf(c -> c.name().equals("Servicepreis"));
|
||||
components.add(new Component(0,"Servicepreis","",service.add(automaticService),ZERO,
|
||||
discount(service,serviceDiscount).add(automaticService),discount(service,serviceDiscount).add(automaticService),courierService,courierService));
|
||||
net=net.add(automaticService);subtotal=subtotal.add(automaticService);
|
||||
}
|
||||
BigDecimal courierAgio=ZERO;
|
||||
if(request.mode()==Mode.RETROSPECTIVE && request.courierVehicleId()!=null && deduction.signum()>0) {
|
||||
var rates=db.queryForList("""
|
||||
SELECT h.srvprch_price FROM servicepricehistory h JOIN couriervehicle v ON v.cr_id=h.srvprch_obj_id
|
||||
WHERE v.crvh_id=? AND h.srvprch_obj_type='cr' AND h.srvprch_fieldname='cscr_prov'
|
||||
AND h.srvprch_context=? AND h.srvprch_validfrom<=? ORDER BY h.srvprch_validfrom DESC LIMIT 1
|
||||
""",request.courierVehicleId(),Long.toString(id(payer,"cs_id")),at);
|
||||
if(!rates.isEmpty())courierAgio=number(rates.getFirst(),"srvprch_price");
|
||||
if(courierAgio.signum()>0) {
|
||||
// global.inc.php:check_cr_agio, applied once on retrospective acceptance.
|
||||
BigDecimal adjusted=discount(subtotal.subtract(service).subtract(automaticService),deduction.subtract(courierAgio));
|
||||
courierSubtotal=adjusted.add(courierService);courierNet=markup(adjusted,courierMarkup).add(courierService);
|
||||
}
|
||||
}
|
||||
if(batch) {net=automaticService;subtotal=automaticService;courierNet=ZERO;courierSubtotal=ZERO;courierBase=ZERO;courierService=ZERO;}
|
||||
if(route!=null && route.longhaul() && request.mode()!=Mode.RETROSPECTIVE) {
|
||||
courierNet=ZERO;courierSubtotal=ZERO;courierBase=ZERO;courierService=ZERO;
|
||||
components=components.stream().map(c -> new Component(c.sequence(),c.name(),c.type(),c.price(),c.discount(),c.net(),c.subtotal(),ZERO,ZERO)).toList();
|
||||
}
|
||||
var tax=one(db.queryForList("SELECT tx_value,tx_sign FROM tax WHERE tx_id=?",input.taxId()),"Bitte einen gültigen Steuerschlüssel wählen.");
|
||||
BigDecimal taxRate=number(tax,"tx_value");String taxSign=text(tax,"tx_sign");
|
||||
if(taxRate.signum()>0 && at.isBefore(LocalDateTime.of(2020,7,1,0,0))) taxRate=new BigDecimal("19");
|
||||
var payerCountries=db.queryForList("SELECT a.ad_country FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? AND ca.adt_id=2",request.payerCostCenterId());
|
||||
if(!payerCountries.isEmpty()) {
|
||||
String country=text(payerCountries.getFirst(),"ad_country");
|
||||
if(!Set.of("","D","DE","LI","NO","IS","HR","MC","AL","MK","CH").contains(country)) { taxRate=ZERO;taxSign="0"; }
|
||||
}
|
||||
BigDecimal payableNet=net.add(nz(request.toll()));
|
||||
BigDecimal taxAmount=money(payableNet.multiply(taxRate).divide(HUNDRED));
|
||||
String limitText=catalog.parameter(0,"JB_CR_PRICE_BLOCK");
|
||||
BigDecimal limit=limitText.isBlank()?ZERO:new BigDecimal(limitText.replace(',','.'));
|
||||
boolean limitExceeded=limit.signum()>0 && courierNet.compareTo(money(net.multiply(limit).divide(HUNDRED)))>0;
|
||||
Quote quote=new Quote(money(net),taxAmount,money(payableNet.add(taxAmount)),money(subtotal),money(courierNet),money(courierSubtotal),
|
||||
markup,courierMarkup,discount,taxRate,taxSign,money(courierBase),money(courierService),deduction,invoiceMode,batch,List.copyOf(components),"",courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet);
|
||||
String fingerprint=hash(user.headquartersId()+"|"+quote.toString()+"|"+request.insurance());
|
||||
return new Quote(quote.net(),quote.tax(),quote.gross(),quote.subtotal(),quote.courierNet(),quote.courierSubtotal(),
|
||||
markup,courierMarkup,discount,taxRate,taxSign,quote.courierBase(),quote.courierService(),deduction,invoiceMode,batch,quote.components(),fingerprint,courierAgio,limit,limitExceeded,route,photos,calculation,autoPrice==null?null:autoPrice.base(),autoPrice==null?null:autoPrice.courier(),money(nz(request.toll())),OrderEntryExtras.insurancePremium(request.insurance()),request.copies(),List.of(),List.of(),List.of(),List.of(),null,photoSet);
|
||||
}
|
||||
|
||||
private BigDecimal streetService(long hq,Request request,LocalDateTime at) {
|
||||
String setting=catalog.parameter(hq,"MASK_SERVICE_PRICE_STREET");
|
||||
if(setting.isBlank())return ZERO;
|
||||
String[] rule=setting.split(",");
|
||||
if(rule.length!=7)throw invalid("Die hinterlegte Regel für den Adresszuschlag ist ungültig.");
|
||||
var day=at.toLocalDate();
|
||||
if(day.isBefore(java.time.LocalDate.parse(rule[4].trim())) || day.isAfter(java.time.LocalDate.parse(rule[5].trim())))return ZERO;
|
||||
BigDecimal result=ZERO;
|
||||
for(Station stop:request.stops()) {
|
||||
if(!stop.postalCode().equals(rule[0].trim()) || !stop.street().startsWith(rule[1].trim()))continue;
|
||||
String digits=stop.houseNumber().replace(" ","").replaceFirst("[^0-9].*$","");
|
||||
if(digits.isBlank())continue;
|
||||
int house=Integer.parseInt(digits);boolean match=false;
|
||||
// PHP tests both configured ranges, without an additional parity test.
|
||||
for(int i=2;i<=3;i++) {String[] range=rule[i].trim().split("-");if(range.length!=2)throw invalid("Ungültiger Hausnummernbereich im Adresszuschlag.");if(house>=Integer.parseInt(range[0])&&house<=Integer.parseInt(range[1]))match=true;}
|
||||
if(match)result=result.add(new BigDecimal(rule[6].trim()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Component component(int sequence,String name,String type,Tariff tariff,BigDecimal markup,BigDecimal courierMarkup,BigDecimal deduction) {
|
||||
BigDecimal cr=tariff.courier().signum()==0?discount(tariff.price(),deduction):tariff.courier();
|
||||
return new Component(sequence,name,type,markup(tariff.price(),markup),tariff.discount(),
|
||||
discount(markup(tariff.price(),markup),tariff.discount()),discount(tariff.price(),tariff.discount()),
|
||||
discount(markup(cr,courierMarkup),tariff.discount()),discount(cr,tariff.discount()));
|
||||
}
|
||||
|
||||
Tariff service(long hq,long cs,String vehicle,String name,LocalDateTime at,BigDecimal customerDiscount) {
|
||||
var type=db.queryForList("SELECT srvt_id,hq_id FROM servicetype WHERE srvt_name=? AND srvt_mode=0 AND hq_id IN (?,0) ORDER BY hq_id DESC LIMIT 1",vehicle,hq);
|
||||
if(type.isEmpty()) return new Tariff(ZERO,ZERO,ZERO,false);
|
||||
long tariffHq=id(type.getFirst(),"hq_id"),typeId=id(type.getFirst(),"srvt_id");
|
||||
var services=db.queryForList("SELECT srv_id FROM service WHERE srv_name=? AND srv_mode=0 AND hq_id=?",name,tariffHq);
|
||||
if(services.isEmpty()) return new Tariff(ZERO,ZERO,ZERO,false);
|
||||
long serviceId=id(services.getFirst(),"srv_id");
|
||||
Tariff specific=serviceValues(tariffHq,cs,serviceId,typeId,at);
|
||||
Tariff common=specific.price().signum()<=0?serviceValues(tariffHq,0,serviceId,typeId,at):specific;
|
||||
Tariff resolved=resolve(specific,common,customerDiscount,false);
|
||||
return new Tariff(placeholder(resolved.price()),resolved.discount(),placeholder(resolved.courier()),resolved.found());
|
||||
}
|
||||
|
||||
private Tariff serviceValues(long hq,long cs,long service,long type,LocalDateTime at) {
|
||||
return values(db.queryForList("""
|
||||
SELECT srvh_price AS price,srvh_discount AS discount,srvh_cr_price AS courier
|
||||
FROM servicehistory WHERE hq_id=? AND cs_id=? AND srv_id=? AND srvt_id=? AND srvh_validfrom<=?
|
||||
ORDER BY srvh_validfrom DESC LIMIT 1
|
||||
""",hq,cs,service,type,at));
|
||||
}
|
||||
|
||||
private BigDecimal fuelMarkup(long hq,long cs,String vehicle,LocalDateTime at,Map<String,Object> customer) {
|
||||
if(id(customer,"cs_markup")!=1) return ZERO;
|
||||
BigDecimal direct=number(customer,"cs_markup_prov");
|
||||
if(direct.signum()!=0) {
|
||||
// PHP checks the first matching entry with a strictly earlier timestamp.
|
||||
for(var history:markupHistory) if(history.customerId()==cs && at.isAfter(LocalDateTime.parse(history.after().replace(' ','T')))) return history.rate();
|
||||
return direct;
|
||||
}
|
||||
Tariff tariff=service(hq,cs,vehicle,"Aufschlag<br>Sprit [%]",at,ZERO);
|
||||
return tariff.price().multiply(HUNDRED.subtract(tariff.discount())).divide(HUNDRED);
|
||||
}
|
||||
|
||||
private BigDecimal fuelCourierMarkup(long hq,long cs,String vehicle,LocalDateTime at,Map<String,Object> customer) {
|
||||
Tariff tariff=service(hq,cs,vehicle,"Aufschlag<br>Sprit [%]",at,ZERO);
|
||||
return tariff.courier().signum()==0?fuelMarkup(hq,cs,vehicle,at,customer):tariff.courier();
|
||||
}
|
||||
|
||||
private BigDecimal courierDeduction(long hq,long cs,String vehicle,LocalDateTime at) {
|
||||
String sql="""
|
||||
SELECT h.srvh_cr_price AS price,h.srvh_discount AS discount,h.srvh_cr_price AS courier
|
||||
FROM servicehistory h JOIN service s ON s.srv_id=h.srv_id JOIN servicetype t ON t.srvt_id=h.srvt_id
|
||||
WHERE h.hq_id=? AND h.cs_id=? AND s.hq_id=h.hq_id AND t.hq_id=h.hq_id
|
||||
AND s.srv_name='Globaler<br>Frachtverg.-Abschlag [%]' AND s.srv_mode=0
|
||||
AND t.srvt_mode=0 AND t.srvt_name=? AND h.srvh_validfrom<=? ORDER BY h.srvh_validfrom DESC LIMIT 1
|
||||
""";
|
||||
BigDecimal rate=values(db.queryForList(sql,hq,cs,vehicle,at)).price();
|
||||
return rate.signum()==0?values(db.queryForList(sql,hq,0,vehicle,at)).price():rate;
|
||||
}
|
||||
|
||||
private Tariff route(long hq,long cs,int vehicle,int mode,Station from,Station to,LocalDateTime at,BigDecimal discount) {
|
||||
long fromZip=zip(hq,from),toZip=zip(hq,to);
|
||||
long fromId=mode==1?fromZip:area(hq,fromZip,from),toId=mode==1?toZip:area(hq,toZip,to);
|
||||
Tariff specific=pair(hq,cs,vehicle,mode,fromId,toId,at);
|
||||
Tariff common=pair(hq,0,vehicle,mode,fromId,toId,at);
|
||||
if(mode>1 && common.price().signum()==0 && catalog.enabled(hq,"MASK_INVMODE_FALLBACK")) common=pair(hq,0,vehicle,1,fromZip,toZip,at);
|
||||
return resolve(specific,common,discount,true);
|
||||
}
|
||||
private long zip(long hq,Station station) { return LegacyAddressTariffs.postal(db,hq,station); }
|
||||
private long area(long hq,long zip,Station station) { return LegacyAddressTariffs.area(db,catalog,hq,zip,station); }
|
||||
|
||||
private Tariff pair(long hq,long cs,int vehicle,int mode,long from,long to,LocalDateTime at) {
|
||||
if(from==0 || to==0)return new Tariff(ZERO,ZERO,ZERO,false);
|
||||
String prefix=mode==1?"srvph":"srvpah",idPrefix=mode==1?"srvp":"srvpa",table=mode==1?"serviceplzhistory":"serviceplzareahistory";
|
||||
String sql="SELECT "+prefix+"_price AS price,"+prefix+"_discount AS discount,"+prefix+"_cr_price AS courier FROM "+table+
|
||||
" WHERE hq_id=? AND cs_id=? AND vht_id=? AND "+idPrefix+"_id=? AND "+idPrefix+"2_id=? AND "+prefix+"_validfrom<=? ORDER BY "+prefix+"_validfrom DESC LIMIT 1";
|
||||
Tariff result=values(db.queryForList(sql,hq,cs,vehicle,from,to,at));
|
||||
return result.price().signum()==0 && vehicle!=0?values(db.queryForList(sql,hq,cs,0,from,to,at)):result;
|
||||
}
|
||||
private int mappedVehicle(long hq,int vehicle) {
|
||||
if(!catalog.enabled(hq,"SERVICE_VEHICLE_TYPE_ENABLED"))return vehicle;
|
||||
for(String mapping:catalog.parameter(hq,"SERVICE_VEHICLE_TYPE_MAPPING").split("\\|")) {
|
||||
String[] pair=mapping.split("=");
|
||||
if(pair.length==2 && pair[0].trim().equals(Integer.toString(vehicle)) && pair[1].trim().matches("[0-9]+")) {
|
||||
int mapped=Integer.parseInt(pair[1].trim());
|
||||
return db.queryForList("SELECT mt_sort FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",mapped).isEmpty()?vehicle:mapped;
|
||||
}
|
||||
}
|
||||
return vehicle;
|
||||
}
|
||||
static Tariff resolve(Tariff specific,Tariff common,BigDecimal customerDiscount,boolean postal) {
|
||||
BigDecimal price=specific.price(),discount=customerDiscount.signum()==0?specific.discount():customerDiscount;
|
||||
BigDecimal correction=price.signum()<0?price:ZERO;
|
||||
// Preserve the active PHP PLZ branch's correction value (services_func.inc.php:691-692).
|
||||
BigDecimal discountCorrection=discount.signum()<0?(postal?price:discount):ZERO;
|
||||
boolean fallback=price.signum()<=0 || (postal && discount.signum()<=0);
|
||||
if(fallback) { if(price.signum()<=0)price=common.price();if(discount.signum()<=0)discount=common.discount(); }
|
||||
return new Tariff(price.add(correction),discount.add(discountCorrection),fallback?common.courier():specific.courier(),
|
||||
specific.found()||common.found());
|
||||
}
|
||||
static Tariff values(List<Map<String,Object>> rows) {
|
||||
return rows.isEmpty()?new Tariff(ZERO,ZERO,ZERO,false):new Tariff(number(rows.getFirst(),"price"),number(rows.getFirst(),"discount"),number(rows.getFirst(),"courier"),true);
|
||||
}
|
||||
static BigDecimal placeholder(BigDecimal value) { return money(value).compareTo(new BigDecimal("0.01"))==0?ZERO:value; }
|
||||
static BigDecimal nz(BigDecimal value) { return value==null?ZERO:value; }
|
||||
static boolean positive(BigDecimal value) { return value!=null && value.signum()>0; }
|
||||
static BigDecimal money(BigDecimal value) { return value.setScale(2,RoundingMode.HALF_UP); }
|
||||
static BigDecimal discount(BigDecimal value,BigDecimal percent) { return money(value.multiply(HUNDRED.subtract(percent)).divide(HUNDRED)); }
|
||||
static BigDecimal markup(BigDecimal value,BigDecimal percent) { return money(value.multiply(HUNDRED.add(percent)).divide(HUNDRED)); }
|
||||
static BigDecimal sum(List<Component> components,java.util.function.Function<Component,BigDecimal> field) { return components.stream().map(field).reduce(ZERO,BigDecimal::add); }
|
||||
static String hash(String text) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
}
|
||||
@@ -78,18 +78,23 @@ public class LegacyReadService {
|
||||
DeliveryOrder o=byId.get(LegacyMapping.id(row,"jb_id"));if(o!=null){Stop s=LegacyMapping.stop(row);o.stops.add(s);if(s.recipientName!=null&&!s.recipientName.isBlank())o.recipientName=s.recipientName;}
|
||||
});
|
||||
}
|
||||
public List<Customer> customers(LegacyUser user,String q) {
|
||||
public List<Customer> customers(LegacyUser user,String q) { return customers(user,q,false,null); }
|
||||
public List<Customer> customers(LegacyUser user,String q,boolean showCostCenters,String costCenterName) {
|
||||
user.require(0);
|
||||
if((q!=null && q.length()>200)||(costCenterName!=null && costCenterName.length()>200))throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Suchtext ist zu lang.");
|
||||
String sql="""
|
||||
SELECT cs.*,cmp.cmp_comp,cmp.cmp_hsno,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived,
|
||||
a.ad_street,a.ad_zipcode,a.ad_city,u.usr_firstname,u.usr_name,u.usr_phone,u.usr_email
|
||||
a.ad_street,a.ad_zipcode,a.ad_city,u.usr_firstname,u.usr_name,u.usr_phone,u.usr_email,
|
||||
(SELECT MIN(mt.mt_mapped_value) FROM metatype mt WHERE mt.mt_type='payment_term' AND mt.mt_sort=cs.cs_payment_term) AS payment_term_label
|
||||
FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id
|
||||
LEFT JOIN address a ON a.ad_id=cmp.ad_id LEFT JOIN employee e ON e.emp_id=cs.cs_admin
|
||||
LEFT JOIN user u ON u.usr_id=e.usr_id WHERE cs.hq_id=?
|
||||
""";
|
||||
if(showCostCenters)sql=sql.replace("SELECT cs.*,", "SELECT csc.csc_id AS app_costcenter_id,csc.csc_name AS app_costcenter_name,cs.*,").replace("WHERE cs.hq_id=?", "JOIN costcenter csc ON csc.cs_id=cs.cs_id WHERE cs.hq_id=?");
|
||||
List<Object> args=new ArrayList<>(List.of(user.headquartersId()));
|
||||
if(showCostCenters && costCenterName!=null && !costCenterName.isBlank()){sql+=" AND csc.csc_name LIKE ?";args.add("%"+costCenterName.trim()+"%");}
|
||||
if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR a.ad_city LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);}
|
||||
return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::customer).toList();
|
||||
return database.queryForList(sql+" ORDER BY cmp.cmp_comp,cs.cs_id"+(showCostCenters?",csc.csc_name,csc.csc_id":"")+" LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::customer).toList();
|
||||
}
|
||||
public List<Courier> couriers(LegacyUser user,String q) {
|
||||
user.require(1);
|
||||
|
||||
@@ -4,12 +4,13 @@ import org.springframework.security.access.AccessDeniedException;
|
||||
import java.security.Principal;
|
||||
import static de.stadtbote.votian.Models.*;
|
||||
|
||||
/** Scope is the original headquarters. Cross-headquarters and customer access are never widened implicitly. */
|
||||
public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights) implements Principal {
|
||||
/** Entry scope can be selected explicitly; the original account headquarters remains immutable. */
|
||||
public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights, long originalHeadquartersId) implements Principal {
|
||||
public LegacyUser(long userId,long employeeId,long headquartersId,String username,String rights) {this(userId,employeeId,headquartersId,username,rights,headquartersId);}
|
||||
@Override public String getName() { return username; }
|
||||
public boolean hasRight(int position) { return rights != null && rights.length() > position && rights.charAt(position) == '1'; }
|
||||
public void require(int position) {
|
||||
if (!hasRight(position)) throw new AccessDeniedException("Für diesen Bereich fehlt die Berechtigung im vorhandenen VoTiAn-Konto.");
|
||||
}
|
||||
public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),false,false,false,false,false,false,hasRight(4) && hasRight(5)); }
|
||||
public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),hasRight(5),hasRight(4)&&hasRight(7),hasRight(0),hasRight(1),hasRight(5)&&hasRight(7),hasRight(4)&&hasRight(7),hasRight(4) && hasRight(5),hasRight(7),hasRight(8),hasRight(0)&&hasRight(11),hasRight(4)||hasRight(18)||(hasRight(0)&&hasRight(17)),hasRight(15)); }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.bad;
|
||||
import static de.stadtbote.votian.OrderIntegrationClient.unavailable;
|
||||
|
||||
/** Places Autocomplete / Details; the API key stays on the server. No business writes. */
|
||||
@RestController
|
||||
@RequestMapping("/api/master-data/{kind}")
|
||||
public class MasterAddressSuggestions {
|
||||
final OrderIntegrationClient client; final OrderRoutingService routing;
|
||||
public MasterAddressSuggestions(OrderIntegrationClient client,OrderRoutingService routing){this.client=client;this.routing=routing;}
|
||||
public record Suggestion(String id,String label) {}
|
||||
public record Address(String street,String houseNumber,String postalCode,String city,String country) {}
|
||||
private void access(LegacyUser user,String kind){switch(kind){case "customers","centers"->user.require(0);case "couriers"->user.require(1);default->throw bad("Dieser Stammdatenbereich enthält keine Adresse.");}}
|
||||
private String key(){String key=routing.apiKey();if(key.isBlank())throw unavailable("Adressvorschläge sind nicht konfiguriert. Die Adresse kann manuell eingegeben werden.");return key;}
|
||||
@GetMapping("/address-suggestions") public List<Suggestion> suggestions(Authentication auth,@PathVariable String kind,@RequestParam String q,@RequestParam(defaultValue="DE")String country,@RequestParam String sessionToken){return suggestions(ApiController.user(auth),kind,q,country,sessionToken);}
|
||||
List<Suggestion> suggestions(LegacyUser user,String kind,String query,String country,String session){
|
||||
access(user,kind);BusinessSupport.token(session);
|
||||
if(query==null||query.length()>200||country==null||!country.matches("[A-Z]{2}"))throw bad("Ungültige Adresssuche.");
|
||||
if(query.strip().length()<3)return List.of();
|
||||
var payload=Map.of("input",query.strip(),"languageCode","de","regionCode",country.toLowerCase(Locale.ROOT),"sessionToken",session,"includedPrimaryTypes",List.of("street_address","route","premise","subpremise"));
|
||||
var response=client.googlePlaces("places:autocomplete",payload,key(),"suggestions.placePrediction.placeId,suggestions.placePrediction.text.text");
|
||||
if(response==null||response.has("error")||response.has("suggestions")&&!response.path("suggestions").isArray())throw unavailable("Der Adressdienst hat ungültige Vorschläge geliefert.");
|
||||
var result=new ArrayList<Suggestion>();for(var item:response.path("suggestions")){var prediction=item.path("placePrediction");String id=prediction.path("placeId").asText(),label=prediction.path("text").path("text").asText();if(validId(id)&&!label.isBlank())result.add(new Suggestion(id,label));if(result.size()==5)break;}return List.copyOf(result);
|
||||
}
|
||||
@GetMapping("/address-details") public Address details(Authentication auth,@PathVariable String kind,@RequestParam String placeId,@RequestParam String sessionToken){return details(ApiController.user(auth),kind,placeId,sessionToken);}
|
||||
Address details(LegacyUser user,String kind,String placeId,String session){
|
||||
access(user,kind);BusinessSupport.token(session);if(!validId(placeId))throw bad("Ungültiger Adressvorschlag.");
|
||||
var result=client.googlePlaces("places/"+placeId+"?languageCode=de&sessionToken="+session,null,key(),"addressComponents");
|
||||
if(result==null||!result.path("addressComponents").isArray())throw unavailable("Die Adresse konnte nicht aufgelöst werden. Bitte manuell ergänzen.");
|
||||
return components(result.path("addressComponents"));
|
||||
}
|
||||
private static boolean validId(String value){return value!=null&&value.matches("[A-Za-z0-9_-]{1,255}");}
|
||||
static Address components(JsonNode nodes){
|
||||
var parts=new HashMap<String,String>();boolean postalPrefix=false;
|
||||
for(var component:nodes)for(var type:component.path("types")){String name=type.asText();if(name.equals("postal_code_prefix"))postalPrefix=true;parts.put(name,component.path(name.equals("country")?"shortText":"longText").asText());}
|
||||
String street=parts.getOrDefault("route",""),country=parts.getOrDefault("country","");
|
||||
if(street.isBlank()||!country.matches("[A-Z]{2}"))throw unavailable("Der Vorschlag enthält keine vollständige Straßenadresse. Bitte eine andere Adresse wählen.");
|
||||
String postal=parts.getOrDefault("postal_code","");if(country.equals("DE")&&(postalPrefix||!postal.matches("[0-9]{5}")))postal="";
|
||||
// Empty components intentionally clear stale values from the previously entered address.
|
||||
return new Address(street,parts.getOrDefault("street_number",""),postal,parts.getOrDefault("locality",parts.getOrDefault("postal_town",parts.getOrDefault("administrative_area_level_3",""))),country);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
|
||||
/** Customer/courier editors: inc_customer, inc_courier, csc_special and courier_vehicle. */
|
||||
@Service
|
||||
public class MasterData {
|
||||
final DatabaseService db; final BusinessSupport support;
|
||||
public MasterData(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;}
|
||||
public record Field(String key,String label,String group,String type,int max,boolean required,Map<String,String> options){}
|
||||
public record Save(String requestId,String revision,Map<String,Object> values){}
|
||||
static Field f(String key,String label,String group,String type,int max,boolean required){return new Field(key,label,group,type,max,required,Map.of());}
|
||||
static Field choice(String key,String label,String group,String... options){var m=new LinkedHashMap<String,String>();for(int i=0;i<options.length;i+=2)m.put(options[i],options[i+1]);return new Field(key,label,group,"select",0,false,m);}
|
||||
static final List<Field> COMMON=List.of(f("tx_id","Umsatzsteuer","Bank und Rechnung","tax",0,true),
|
||||
f("cmp_comp","Firma","Firma","text",50,true),f("cmp_comp2","Zusatz 1","Firma","text",50,false),f("cmp_comp3","Zusatz 2","Firma","text",50,false),f("cmp_comp4","Zusatz 3","Firma","text",50,false),
|
||||
f("cmp_match","Suchbegriff","Firma","text",200,false),f("cmp_match2","Weiterer Suchbegriff","Firma","text",50,false),
|
||||
f("ad_street","Straße","Adresse","text",50,true),f("cmp_hsno","Hausnummer","Adresse","text",10,true),f("ad_zipcode","PLZ","Adresse","text",10,true),f("ad_city","Ort","Adresse","text",50,true),f("ad_country","Land (ISO)","Adresse","country",2,true),
|
||||
f("usr_name","Nachname","Kontakt","text",50,false),f("usr_firstname","Vorname","Kontakt","text",50,false),f("usr_phone","Telefon","Kontakt","text",20,true),f("usr_phone2","Mobiltelefon","Kontakt","text",20,false),f("usr_fax","Fax","Kontakt","text",20,false),f("usr_email","E-Mail","Kontakt","emails",250,false),f("usr_inv_email","Rechnungs-E-Mail","Kontakt","emails",250,false),f("usr_reminder_email","Mahn-E-Mail","Kontakt","emails",250,false),
|
||||
f("cmp_tax_idno","Steuernummer","Abrechnung","text",30,false),f("cmp_stax_idno","USt-IdNr.","Abrechnung","text",30,false),f("cmp_bank","Bank","Abrechnung","text",30,false),f("cmp_bankno","Bankleitzahl","Abrechnung","text",20,false),f("cmp_bankacc","Kontonummer","Abrechnung","text",20,false),f("cmp_iban","IBAN","Abrechnung","text",40,false),f("cmp_swift","BIC / Swift","Abrechnung","text",20,false),choice("cmp_bankmode","Zahlungsart","Abrechnung","0","Rechnung","1","Einzug"),f("cmp_hqlino","Lieferantennummer","Abrechnung","text",20,false),f("cmp_gln","GLN","Abrechnung","text",20,false),f("cmp_postage","Porto (€)","Abrechnung","money",999,false),
|
||||
f("cmp_contract_date","Vertragsdatum","Vertrag und Status","date",0,false),f("cmp_visible","Sichtbar","Vertrag und Status","check",1,false),f("cmp_authenticated","Freigegeben","Vertrag und Status","check",1,false),f("cmp_archived","Archiviert","Vertrag und Status","check",1,false),
|
||||
f("cmp_remark","Bemerkung","Notizen","textarea",10000,false),f("cmp_remark2","Interne Bemerkung","Notizen","textarea",10000,false),f("cmp_freetext1","Freitext","Notizen","textarea",10000,false));
|
||||
static final List<Field> CUSTOMER=List.of(
|
||||
f("cs_eid","Kundennummer / ExtID","Firma","text",10,true),
|
||||
choice("cs_invmode","Preisermittlung","Abrechnung","0","PZM","1","PLZ","2","Bereich"),
|
||||
f("cs_payment_term","Zahlungsbedingung","Abrechnung","payment",0,false),f("cs_discount","Rabatt (%)","Konditionen","percent",100,false),f("cs_fixprice_discount","Rabatt auf Fixpreise","Konditionen","check",1,false),f("cs_prov","Provision (%)","Konditionen","percent",100,false),f("cs_markup","Treibstoffaufschlag erheben","Konditionen","check",1,false),f("cs_markup_prov","Treibstoffaufschlag (%)","Konditionen","percent",100,false),
|
||||
f("cs_commission_no","Kommissionsnummer verpflichtend","Abrechnung","check",1,false),f("cs_jb_commission_no","Rechnungskommissionsnummer","Abrechnung","check",1,false),f("cs_jobbatch","Listenkunde","Auftragsvorgaben","check",1,false),f("cs_cr_fav_only","Nur angeforderte Kuriere","Auftragsvorgaben","check",1,false),f("cs_invtext_mode","Rechnungskurztext","Abrechnung","check",1,false),f("cs_tracking","Sendungsverfolgung","Auftragsvorgaben","check",1,false),f("cs_charging_time","Ladedauer (Minuten)","Auftragsvorgaben","integer",127,false),f("cs_blur_markup_time","Zeitaufschlag (Minuten)","Auftragsvorgaben","integer",127,false),f("cmp_no_longhaul","Keine bundesweite Vermittlung","Auftragsvorgaben","check",1,false),
|
||||
f("cs_jbstatusmail","E-Mail bei Erledigung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail2","E-Mail bei Abholung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail3","E-Mail bei Erfassung","Benachrichtigungen","check",1,false),f("cs_jbstatusmail2csc","Rechnungsadresse der Kostenstelle verwenden","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_price","Preis anzeigen","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_emp","Mitarbeiterdaten anzeigen","Benachrichtigungen","check",1,false),f("cs_jbstatusmail_pdf","PDF anhängen","Benachrichtigungen","check",1,false),
|
||||
f("cmp_sustainability_email","Nachhaltigkeits-E-Mail","Benachrichtigungen","emails",250,false),f("cmp_sustainability_interval","Nachhaltigkeitsbericht","Benachrichtigungen","reportInterval",0,false));
|
||||
static final List<Field> COURIER=List.of(f("vht_id_inv","Abrechnungsfahrzeugtyp","Abrechnung","vehicle",0,false),f("cr_imei","Geräte-IMEI","Gerät","text",20,false),f("cr_serialno","Seriennummer","Gerät","text",30,false),f("cr_mobile_pda","Gerätetelefon","Gerät","text",30,false),f("cmp_no_longhaul","Keine bundesweite Vermittlung","Disposition","check",1,false),f("cr_eid","Kuriernummer / ExtID","Firma","text",10,true),f("cr_maxweight","Maximale Zuladung (kg)","Disposition","integer",100000,false),f("cr_outlay","Maximale Auslage (€)","Disposition","integer",100000,false),f("cr_locationzipcode","Standort-PLZ","Disposition","text",10,false),f("cr_disposition","Disposition aktiviert","Disposition","check",1,false),f("cr_price_show","Preise anzeigen","Disposition","check",1,false));
|
||||
static List<Field> centerFields(){var all=new ArrayList<>(CENTER);String[] groups={"","Leistungsanschrift","Rechnungsanschrift","Zusatzadresse (Typ 3)","Abhol- / Lieferadresse"};for(int type=1;type<=4;type++)for(var f:CENTER_ADDRESS)all.add(new Field("addr"+type+"_"+f.key(),f.label(),groups[type],f.type(),f.max(),type!=3&&f.required(),f.options()));return all;}
|
||||
static final List<Field> CENTER_ADDRESS=List.of(f("cscad_comp","Firma","","text",50,true),f("cscad_comp2","Zusatz","","text",50,false),f("cscad_comp3","Zusatz 2","","text",50,false),f("cscad_comp4","Zusatz 3","","text",50,false),f("ad_street","Straße","","text",50,true),f("cscad_hsno","Hausnummer","","text",10,false),f("ad_zipcode","PLZ","","text",10,true),f("ad_city","Ort","","text",50,true),f("ad_country","Land (ISO)","","country",2,true),f("cscad_person","Ansprechpartner","","text",50,false),f("cscad_phone","Telefon","","text",20,false),f("cscad_email","E-Mail","","emails",100,false),f("cscad_gln","GLN","","text",20,false),f("cscad_remark","Bemerkung","","textarea",10000,false));
|
||||
static final List<Field> CENTER=List.of(f("csc_name","Kostenstellenname","Kostenstelle","text",50,true),f("csc_pre_id","Übergeordnete Kostenstelle","Kostenstelle","parent",0,true),f("csc_visible","Sichtbar","Kostenstelle","check",1,false),f("csc_blocked","Gesperrt","Kostenstelle","check",1,false),f("csc_is_extern","Externe Kostenstelle","Kostenstelle","check",1,false),f("csc_inv_interval","Rechnungsintervall","Abrechnung","interval",0,false),f("csc_inv_type","Rechnungstyp","Abrechnung","invoiceType",0,false));
|
||||
static final List<Field> VEHICLE=List.of(f("crvh_sid","Fahrzeugnummer / SID","Fahrzeug","text",10,true),f("vht_id","Fahrzeugtyp","Fahrzeug","vehicle",0,true),f("crvh_vh_sign","Kennzeichen","Fahrzeug","text",10,false),f("crvh_vh_name","Bezeichnung","Fahrzeug","text",30,false),f("crvh_comm_no","Kommissionsnummer","Fahrzeug","text",10,false),f("crvh_mobile","Mobiltelefon","Fahrzeug","text",20,false),f("crvh_licence_date","Erstzulassung","Fahrzeug","date",0,false),f("crvh_payload","Zuladung (kg)","Laderaum","integer",100000,false),f("crvh_totalweight","Gesamtgewicht (kg)","Laderaum","integer",100000,false),f("crvh_length","Länge (cm)","Laderaum","integer",10000,false),f("crvh_width","Breite (cm)","Laderaum","integer",10000,false),f("crvh_height","Höhe (cm)","Laderaum","integer",10000,false),f("crvh_position","Stellplätze","Laderaum","integer",1000,false),f("crvh_prov","Provision (%)","Abrechnung","percent",100,false),f("crvh_insurance_name","Versicherung","Vertrag","text",30,false),f("crvh_insurance_no","Versicherungsnummer","Vertrag","text",30,false),f("crvh_remark","Bemerkung","Vertrag","text",30,false),f("crvh_vhd_disabled","Fahrzeugdisposition deaktiviert","Fahrzeug","check",1,false),f("crvh_freetext_1","Freitext","Vertrag","textarea",10000,false));
|
||||
List<Field> fieldsFor(String kind){return switch(kind){case "customers"->{var a=new ArrayList<>(COMMON);a.addAll(CUSTOMER);yield a;}case "couriers"->{var a=new ArrayList<>(COMMON);a.addAll(COURIER);yield a;}case "centers"->centerFields();case "vehicles"->VEHICLE;default->throw bad("Unbekannter Stammdatenbereich.");};}
|
||||
void access(LegacyUser u,String kind){u.require(kind.equals("customers")||kind.equals("centers")?0:1);fieldsFor(kind);}
|
||||
public Map<String,Object> view(LegacyUser u,String kind,long id,long parent){access(u,kind);return db.readTransaction(()->{
|
||||
Map<String,Object> row=id==0?new LinkedHashMap<>():load(u,kind,id,false);
|
||||
if(kind.equals("centers")||kind.equals("vehicles")){load(u,kind.equals("centers")?"customers":"couriers",parent,false);if(id>0&&id(row,kind.equals("centers")?"cs_id":"cr_id")!=parent)throw bad("Falsche Zuordnung.");}
|
||||
var values=new LinkedHashMap<String,Object>();for(var f:fieldsFor(kind)){Object value=row.get(f.key());if(value instanceof Boolean b)value=b?1:0;if(value!=null&&f.type().equals("date"))value=value.toString().startsWith("0000-")?"":value.toString().substring(0,10);if(f.type().equals("country")&&"D".equals(value))value="DE";values.put(f.key(),value==null?defaultValue(f):value);}
|
||||
if(id==0&&kind.equals("centers")){var customer=load(u,"customers",parent,false);values.put("csc_pre_id",id(customer,"csc_id"));for(int type:List.of(1,2,4))for(var f:CENTER_ADDRESS){String key=f.key().replace("cscad_","cmp_");if(f.key().equals("cscad_person"))key="usr_name";if(f.key().equals("cscad_phone"))key="usr_phone";if(f.key().equals("cscad_email"))key="usr_inv_email";values.put("addr"+type+"_"+f.key(),customer.getOrDefault(key,defaultValue(f)));}}
|
||||
|
||||
var result=fields("id",id,"parent",parent,"fields",fieldsFor(kind),"values",values,"revision",revision(row),"options",options(u,kind,parent),"canManageServices",kind.equals("customers")&&u.hasRight(2));
|
||||
if(id>0&&kind.equals("customers"))result.put("children",db.queryForList("SELECT csc_id AS id,csc_name AS label,csc_pre_id AS parent,csc_blocked AS blocked FROM costcenter WHERE cs_id=? ORDER BY csc_path,csc_name,csc_id",id));
|
||||
if(id>0&&kind.equals("couriers"))result.put("children",db.queryForList("SELECT crvh_id AS id,CONCAT(crvh_sid,' · ',COALESCE(crvh_vh_sign,'')) AS label FROM couriervehicle WHERE cr_id=? ORDER BY crvh_sid,crvh_id",id));
|
||||
return result;
|
||||
});}
|
||||
Object defaultValue(Field f){return switch(f.type()){case "country"->"DE";case "check"->Set.of("cmp_visible","cmp_authenticated","csc_visible").contains(f.key())?1:0;case "integer","money","percent","vehicle","parent","interval","invoiceType","payment","tax","reportInterval","select"->0;default->"";};}
|
||||
Map<String,Object> options(LegacyUser u,String kind,long parent){var out=new LinkedHashMap<String,Object>();
|
||||
out.put("tax",db.queryForList("SELECT tx_id AS id,CONCAT(tx_sign,' · ',tx_value,' %') AS label FROM tax ORDER BY tx_id"));
|
||||
out.put("vehicle",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort"));
|
||||
out.put("payment",db.queryForList("SELECT mt_sort AS id,mt_mapped_value AS label FROM metatype WHERE mt_type='payment_term' ORDER BY mt_sort"));
|
||||
out.put("reportInterval",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='interval' ORDER BY mt_sort"));
|
||||
out.put("interval",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='invoiceinterval' ORDER BY mt_sort"));
|
||||
out.put("invoiceType",db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='invoicetype' ORDER BY mt_sort"));
|
||||
if(kind.equals("centers"))out.put("parent",db.queryForList("SELECT csc_id AS id,csc_name AS label FROM costcenter WHERE cs_id=? ORDER BY csc_name",parent));return out;
|
||||
}
|
||||
Map<String,Object> load(LegacyUser u,String kind,long id,boolean lock){
|
||||
String sql=switch(kind){
|
||||
case "customers"->"SELECT cs.*,cmp.*,ad.*,usr.usr_id,usr.usr_name,usr.usr_firstname,usr.usr_phone,usr.usr_phone2,usr.usr_fax,usr.usr_email,usr.usr_inv_email,usr.usr_reminder_email,usr.usr_modify FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id JOIN address ad ON ad.ad_id=cmp.ad_id JOIN employee emp ON emp.emp_id=cs.cs_admin JOIN user usr ON usr.usr_id=emp.usr_id WHERE cs.cs_id=? AND cs.hq_id=?";
|
||||
case "couriers"->"SELECT cr.*,cmp.*,ad.*,usr.usr_name,usr.usr_firstname,usr.usr_phone,usr.usr_phone2,usr.usr_fax,usr.usr_email,usr.usr_inv_email,usr.usr_reminder_email,usr.usr_modify FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN address ad ON ad.ad_id=cmp.ad_id JOIN user usr ON usr.usr_id=cr.usr_id WHERE cr.cr_id=? AND cr.hq_id=?";
|
||||
case "centers"->"SELECT c.* FROM costcenter c JOIN customer cs ON cs.cs_id=c.cs_id WHERE c.csc_id=? AND cs.hq_id=?";
|
||||
case "vehicles"->"SELECT v.* FROM couriervehicle v JOIN courier c ON c.cr_id=v.cr_id WHERE v.crvh_id=? AND c.hq_id=?";
|
||||
default->throw bad("Unbekannter Stammdatenbereich.");};
|
||||
var row=new LinkedHashMap<>(one(lock?db.lockRows(sql,id,u.headquartersId()):db.queryForList(sql,id,u.headquartersId())));
|
||||
if(kind.equals("centers")){String addressSql="SELECT ca.*,a.* FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id WHERE ca.csc_id=? ORDER BY ca.adt_id";for(var address:lock?db.lockRows(addressSql,id):db.queryForList(addressSql,id))address.forEach((k,v)->row.put("addr"+id(address,"adt_id")+"_"+k,v));}return row;
|
||||
}
|
||||
public Map<String,Object> save(LegacyUser u,String kind,long target,long parent,Save request){access(u,kind);if(request==null||request.values()==null)throw bad("Stammdaten fehlen.");token(request.requestId());
|
||||
var clean=validate(kind,request.values());
|
||||
support.transactional("headquarters","genericdatacontainer","company","customer","courier","user","employee","address","costcenter","costcenteraddress","couriervehicle","servicepricehistory");
|
||||
long saved=db.writeTransaction(()->{
|
||||
// ExtIDs and SIDs are shared with PHP; lock matching index ranges as well as our creation mutex.
|
||||
one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",u.headquartersId()));
|
||||
Long receipt=support.receipt(u,request.requestId(),List.of(kind,target,parent,request));if(receipt!=null)return receipt;
|
||||
Map<String,Object> old=target==0?new LinkedHashMap<>():load(u,kind,target,true);if(target>0)unchanged(old,request.revision());
|
||||
long result=target;
|
||||
if(kind.equals("customers")||kind.equals("couriers")){
|
||||
boolean cs=kind.equals("customers");String table=cs?"customer":"courier",key=cs?"cs_id":"cr_id",eid=cs?"cs_eid":"cr_eid";
|
||||
var duplicates=db.lockRows("SELECT "+key+" FROM "+table+" WHERE "+eid+"=? AND "+key+"<>?"+(cs?"":" AND hq_id=?"),cs?new Object[]{clean.get(eid),target}:new Object[]{clean.get(eid),target,u.headquartersId()});if(!duplicates.isEmpty())throw bad("Diese ExtID ist bereits vergeben.");
|
||||
if(cs&&priceChanged(old,clean)&&List.of("cs_discount","cs_prov","cs_markup_prov").stream().anyMatch(k->decimal(clean,k).signum()!=0 || (target>0 && decimal(old,k)!=null && decimal(old,k).signum()!=0)))u.require(2);
|
||||
var company=prefix(clean,"cmp_");company.put("tx_id",clean.get("tx_id"));var person=prefix(clean,"usr_");var entity=prefix(clean,cs?"cs_":"cr_");if(!cs)entity.put("vht_id_inv",clean.get("vht_id_inv"));
|
||||
long addressId=id(old,"ad_id");var address=prefix(clean,"ad_");boolean changed=target==0||address.entrySet().stream().anyMatch(e->!Objects.toString(old.get(e.getKey()),"").equals(e.getValue().toString()));
|
||||
if(changed)addressId=db.insert("address",address); // Never mutate shared addresses referenced by past jobs.
|
||||
company.put("ad_id",addressId);company.put("cmp_modify_status",1);
|
||||
if(target==0){
|
||||
company.put("cmp_fingerprint","");company.put("cmp_new_date",LocalDateTime.now());company.putIfAbsent("cmp_contract_date","0000-00-00");company.putIfAbsent("cmp_sustainability_email","");company.put("cmp_newsletter",0);
|
||||
long cmp=db.insert("company",company);
|
||||
person.putAll(fields("hq_id",u.headquartersId(),"usr_type",cs?2:3,"usr_country","","usr_account","","usr_password",null,"usr_password_old","","usr_totp_secret","","usr_totp_sessionkey",""));long usr=db.insert("user",person);
|
||||
entity.putAll(fields("hq_id",u.headquartersId(),"cmp_id",cmp));
|
||||
if(cs){long center=db.insert("costcenter",fields("csc_name",clean.get("cmp_comp"),"csc_visible",1));long emp=db.insert("employee",fields("usr_id",usr,"csc_id",center,"emp_rights","0".repeat(70),"emp_heritable_rights","0".repeat(70),"emp_headquarters",""));entity.putAll(fields("csc_id",center,"cs_admin",emp,"cs_become_cs_date",LocalDate.now()));result=db.insert(table,entity);db.update("UPDATE costcenter SET cs_id=? WHERE csc_id=?",result,center);for(int type:List.of(1,2,4))addressCopy(center,type,addressId,clean);}
|
||||
else{entity.putAll(fields("usr_id",usr,"cr_filter","","cr_device_info","","cr_mobile_cell",""));result=db.insert(table,entity);}
|
||||
}else{update(db,"company","cmp_id",id(old,"cmp_id"),company);update(db,"user","usr_id",id(old,"usr_id"),person);update(db,table,key,result,entity);}
|
||||
if(cs)for(String field:List.of("cs_discount","cs_prov","cs_markup_prov"))if(target==0||decimalChanged(old,clean,field))history("cs",result,clean.get(eid).toString(),field,clean.get(field));
|
||||
}else if(kind.equals("centers")){
|
||||
var customer=load(u,"customers",parent,true);if(target>0&&id(old,"cs_id")!=parent)throw bad("Falscher Kunde.");
|
||||
long ancestor=((Number)clean.get("csc_pre_id")).longValue();boolean root=target==id(customer,"csc_id");if(root){if(ancestor!=0)throw bad("Die Hauptkostenstelle kann nicht verschoben werden.");clean.put("csc_pre_id",null);}else{
|
||||
var seen=new HashSet<Long>();long cursor=ancestor;while(cursor>0){if(cursor==target||!seen.add(cursor))throw bad("Kostenstellen dürfen keinen Kreis bilden.");var p=load(u,"centers",cursor,true);if(id(p,"cs_id")!=parent)throw bad("Die übergeordnete Kostenstelle gehört zu einem anderen Kunden.");cursor=id(p,"csc_pre_id");}if(ancestor<=0)throw bad("Bitte eine übergeordnete Kostenstelle wählen.");}
|
||||
if(clean.get("csc_name").toString().contains("//"))throw bad("Kostenstellennamen dürfen kein // enthalten.");
|
||||
var center=prefix(clean,"csc_");center.put("cs_id",parent);if(target==0)result=db.insert("costcenter",center);else update(db,"costcenter","csc_id",result,center);
|
||||
for(int type=1;type<=4;type++){String pre="addr"+type+"_";if(type==3&&id(old,pre+"ad_id")==0&&!optionalAddressPresent(clean))continue;var address=new LinkedHashMap<String,Object>();var info=new LinkedHashMap<String,Object>();for(var f:CENTER_ADDRESS){if(f.key().startsWith("ad_"))address.put(f.key(),clean.get(pre+f.key()));else info.put(f.key(),clean.get(pre+f.key()));}
|
||||
long addressId=id(old,pre+"ad_id");boolean changed=target==0||addressId==0||address.entrySet().stream().anyMatch(e->!Objects.toString(old.get(pre+e.getKey()),"").equals(Objects.toString(e.getValue(),"")));if(changed)addressId=db.insert("address",address);info.put("ad_id",addressId);
|
||||
db.update("DELETE FROM costcenteraddress WHERE csc_id=? AND adt_id=?",result,type);info.putAll(fields("csc_id",result,"adt_id",type));insertWithoutKey(db,"costcenteraddress",info);
|
||||
}
|
||||
rebuildPaths(parent);
|
||||
}else{
|
||||
load(u,"couriers",parent,true);if(target>0&&id(old,"cr_id")!=parent)throw bad("Falscher Transporteur.");
|
||||
if(decimalChanged(old,clean,"crvh_prov")&&(target>0||decimal(clean,"crvh_prov").signum()!=0))u.require(41);
|
||||
if(!db.lockRows("SELECT crvh_id FROM couriervehicle WHERE crvh_sid=? AND crvh_id<>?",clean.get("crvh_sid"),target).isEmpty())throw bad("Die Fahrzeug-SID ist bereits vergeben.");
|
||||
if(db.queryForList("SELECT mt_id FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",clean.get("vht_id")).isEmpty())throw bad("Unbekannter Fahrzeugtyp.");
|
||||
clean.put("cr_id",parent);clean.put("crvh_modify_status",1);
|
||||
if(target==0){clean.putAll(fields("crvh_filter","","crvh_service","","crvh_funding_state",0,"crvh_funding_residual_value",0,"crvh_partner_text","","crvh_transport_insurance_date","0000-00-00","crvh_business_liability_insurance_date","0000-00-00"));result=db.insert("couriervehicle",clean);}else update(db,"couriervehicle","crvh_id",result,clean);
|
||||
}
|
||||
support.audit(u,0,kind.equals("customers")?result:kind.equals("centers")?parent:0,kind.equals("couriers")?result:kind.equals("vehicles")?parent:0,kind.equals("vehicles")?(target==0?85:86):csOperation(kind,target),"Stammdaten "+kind+" #"+result+" · "+(target==0?"Angelegt":"Bearbeitet")+" · Felder: "+String.join(",",clean.keySet()),request.requestId());
|
||||
support.receipt(u,request.requestId(),List.of(kind,target,parent,request),result);return result;
|
||||
});return view(u,kind,saved,parent);
|
||||
}
|
||||
static int csOperation(String kind,long id){return kind.equals("customers")?(id==0?34:36):kind.equals("couriers")?(id==0?35:37):49;}
|
||||
static boolean decimalChanged(Map<String,Object> a,Map<String,Object> b,String k){BigDecimal x=decimal(a,k),y=decimal(b,k);return x==null||y==null||x.compareTo(y)!=0;}
|
||||
static boolean priceChanged(Map<String,Object>a,Map<String,Object>b){return List.of("cs_discount","cs_prov","cs_markup_prov").stream().anyMatch(k->decimalChanged(a,b,k));}
|
||||
void history(String type,long id,String eid,String field,Object value){insertWithoutKey(db,"servicepricehistory",fields("srvprch_obj_type",type,"srvprch_obj_id",id,"srvprch_obj_eid",eid,"srvprch_fieldname",field,"srvprch_context","","srvprch_price",value,"srvprch_validfrom",LocalDateTime.now().withNano(0)));}
|
||||
void addressCopy(long center,int type,long address,Map<String,Object> v){insertWithoutKey(db,"costcenteraddress",fields("csc_id",center,"adt_id",type,"ad_id",address,"cscad_comp",text(v,"cmp_comp"),"cscad_comp2",text(v,"cmp_comp2"),"cscad_comp3",text(v,"cmp_comp3"),"cscad_comp4",text(v,"cmp_comp4"),"cscad_hsno",text(v,"cmp_hsno"),"cscad_person",join(text(v,"usr_firstname"),text(v,"usr_name")),"cscad_phone",text(v,"usr_phone"),"cscad_email",text(v,"usr_inv_email"),"cscad_gln",text(v,"cmp_gln")));}
|
||||
void rebuildPaths(long customer){var rows=db.lockRows("SELECT csc_id,csc_name,csc_pre_id FROM costcenter WHERE cs_id=?",customer);var byId=new HashMap<Long,Map<String,Object>>();rows.forEach(r->byId.put(id(r,"csc_id"),r));for(var r:rows){var names=new ArrayList<String>();var seen=new HashSet<Long>();long cursor=id(r,"csc_pre_id");while(cursor>0){if(!seen.add(cursor)||!byId.containsKey(cursor))throw bad("Kostenstellenhierarchie ist ungültig.");var p=byId.get(cursor);names.addFirst(text(p,"csc_name"));cursor=id(p,"csc_pre_id");}db.update("UPDATE costcenter SET csc_path=? WHERE csc_id=?",names.isEmpty()?"":"//"+String.join("//",names)+"//",id(r,"csc_id"));}}
|
||||
static LinkedHashMap<String,Object> prefix(Map<String,Object> v,String prefix){var out=new LinkedHashMap<String,Object>();v.forEach((k,x)->{if(k.startsWith(prefix))out.put(k,x);});return out;}
|
||||
Map<String,Object> validate(String kind,Map<String,Object> values){var result=validateFields(fieldsFor(kind),kind,values);if(kind.equals("centers")&&optionalAddressPresent(result))for(var field:CENTER_ADDRESS)if(field.required()&&text(result,"addr3_"+field.key()).isBlank())throw bad("Zusatzadresse: "+field.label()+" fehlt.");if(result.containsKey("tx_id")&&db.queryForList("SELECT tx_id FROM tax WHERE tx_id=?",result.get("tx_id")).isEmpty())throw bad("Bitte eine gültige Umsatzsteuer wählen.");for(var f:fieldsFor(kind)){String type=switch(f.type()){case "payment"->"payment_term";case "reportInterval"->"interval";case "interval"->"invoiceinterval";case "invoiceType"->"invoicetype";case "vehicle"->"vehicletype";default->null;};if(type!=null&&((Number)result.get(f.key())).longValue()!=0&&db.queryForList("SELECT mt_id FROM metatype WHERE mt_type=? AND mt_sort=?",type,result.get(f.key())).isEmpty())throw bad(f.label()+": Ungültige Auswahl.");}return result;}
|
||||
static boolean optionalAddressPresent(Map<String,Object> values){return CENTER_ADDRESS.stream().filter(f->!f.key().equals("ad_country")).anyMatch(f->!text(values,"addr3_"+f.key()).isBlank());}
|
||||
static Map<String,Object> validateFields(List<Field> allowed,String kind,Map<String,Object> values){var result=new LinkedHashMap<String,Object>();if(values.keySet().stream().anyMatch(k->allowed.stream().noneMatch(f->f.key().equals(k))))throw bad("Unbekanntes Stammdatenfeld.");
|
||||
for(var f:allowed){String v=Objects.toString(values.get(f.key()),"").trim();Object parsed=v;
|
||||
if(f.required()&&v.isEmpty())throw bad(f.label()+" fehlt.");
|
||||
switch(f.type()){
|
||||
case "check"->{if(!Set.of("0","1","true","false","").contains(v))throw bad(f.label()+": Ungültiger Wert.");parsed=Set.of("1","true").contains(v)?1:0;}
|
||||
case "integer","money","percent","parent","vehicle","payment","interval","invoiceType","tax","reportInterval"->{try{BigDecimal n=new BigDecimal(v.isEmpty()?"0":v.replace(',','.'));boolean decimal=Set.of("money","percent").contains(f.type());if(n.signum()<0||n.compareTo(BigDecimal.valueOf(f.max()>0?f.max():Integer.MAX_VALUE))>0||n.stripTrailingZeros().scale()>(decimal?2:0))throw new NumberFormatException();parsed=decimal?n:n.longValueExact();}catch(ArithmeticException|NumberFormatException e){throw bad(f.label()+": Ungültige Zahl.");}}
|
||||
case "select"->{if(!f.options().containsKey(v))throw bad(f.label()+": Ungültige Auswahl.");parsed=Integer.parseInt(v);}
|
||||
case "datetime-local"->{try{parsed=v.isEmpty()?null:LocalDateTime.parse(v.replace(' ','T'));}catch(Exception e){throw bad(f.label()+": Ungültiger Zeitpunkt.");}}
|
||||
case "date"->{try{parsed=v.isEmpty()?(kind.equals("vehicles")?null:"0000-00-00"):LocalDate.parse(v);}catch(Exception e){throw bad(f.label()+": Ungültiges Datum.");}}
|
||||
default->{if(v.length()>f.max())throw bad(f.label()+" ist zu lang.");if(f.type().equals("country")&&(!v.isEmpty()||f.required())&&!v.matches("[A-Z]{2}"))throw bad("Das Land benötigt einen ISO-Ländercode wie DE.");if(f.type().equals("emails")&&!v.isEmpty())for(String email:v.split("[;,]"))if(!email.trim().matches("[^\\s@]+@[^\\s@]+\\.[^\\s@]+"))throw bad(f.label()+": Ungültige E-Mail-Adresse.");}
|
||||
}result.put(f.key(),parsed);
|
||||
}return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Native counterparts of newsticker.php and mf_history.php. */
|
||||
@Service
|
||||
class MessageAdministration {
|
||||
final DatabaseService db;
|
||||
final String ticker,forum;
|
||||
final BusinessSupport audit;
|
||||
private final Map<String,Pending> previews=new LinkedHashMap<>();
|
||||
private static class Pending {
|
||||
long user,hq,reply; int type; String subject,body,replyRevision,recipientRevision;
|
||||
Dispatch draft; List<Map<String,Object>> recipients; Instant expires;
|
||||
boolean sending; Map<String,Object> result;
|
||||
}
|
||||
record Bulletin(LocalDate from,LocalDate to,long headquarters,long employee,String text) {}
|
||||
record Query(LocalDate from,LocalDate to,List<Integer> states,int maxPriority,String direction,Long courier,int page) {}
|
||||
record Dispatch(String subject,String body,Long courier,List<Long> groups,boolean allActive,Long replyTo,String revision) {}
|
||||
record GroupSave(String revision,String name,List<Long> groups) {}
|
||||
MessageAdministration(DatabaseService db,@Value("${app.history-schema:phoenix_log}") String history,@Value("${app.groupware-schema:phoenix_group}") String groupware) {
|
||||
this.db=db;
|
||||
this.audit=new BusinessSupport(db,new com.fasterxml.jackson.databind.ObjectMapper(),history);
|
||||
if(!history.matches("[A-Za-z0-9_]{1,64}")||!groupware.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Nachrichtenschema.");
|
||||
ticker="`"+groupware+"`.tickerforum";forum="`"+history+"`.messageforum";
|
||||
}
|
||||
List<Map<String,Object>> headquarters(LegacyUser u) {
|
||||
return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(u.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY label",u.hasRight(10)?new Object[]{u.headquartersId()}:new Object[]{u.headquartersId(),u.headquartersId()});
|
||||
}
|
||||
int limit(LegacyUser u,String key,int fallback) {
|
||||
var rows=db.queryForList("SELECT par_value FROM parameter WHERE par_key=? AND emp_id=0 AND hq_id=?",key,u.headquartersId());
|
||||
if(rows.size()>1)throw bad("Mehrdeutige Nachrichteneinstellung: "+key);
|
||||
try {int n=Integer.parseInt(rows.isEmpty()?"":text(rows.getFirst(),"par_value"));return n>0?Math.min(n,16000):fallback;}catch(NumberFormatException e){return fallback;}
|
||||
}
|
||||
Map<String,Object> options(LegacyUser u) {
|
||||
if(!u.hasRight(4)&&!u.hasRight(18)&&!(u.hasRight(0)&&u.hasRight(17)))throw new AccessDeniedException("Keine Nachrichtenberechtigung.");
|
||||
var hqs=headquarters(u);var ids=hqs.stream().map(r->id(r,"id")).toList();
|
||||
return fields("newsletters",u.hasRight(0)&&u.hasRight(17),"devices",u.hasRight(4),"bulletins",u.hasRight(18),"manageGroups",u.hasRight(4)&&u.hasRight(52),"allActive",u.hasRight(4)&&u.hasRight(52)&&u.hasRight(10),
|
||||
"today",LocalDate.now(),"headquartersId",u.headquartersId(),"headquarters",hqs,"maxBulletin",limit(u,"NEWSTICKER_MAX_BODY_LENGTH",1000),"maxMessage",limit(u,"MESSAGE_MAX_BODY_LENGTH",250),
|
||||
"employees",u.hasRight(18)&&!ids.isEmpty()?db.queryForList("SELECT e.emp_id AS id,u.hq_id AS headquarters,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE u.usr_type=1 AND u.hq_id IN ("+CrmScope.marks(ids.size())+") ORDER BY label",ids.toArray()):List.of());
|
||||
}
|
||||
private String bulletinScope(){return "(t.emp_id_creator=? OR t.emp_id=? OR (COALESCE(t.emp_id,0)=0 AND (t.hq_id=? OR COALESCE(t.hq_id,0)=0)))";}
|
||||
private boolean root(LegacyUser u){return !db.queryForList("SELECT hq_id FROM headquarters WHERE hq_id=? AND hq_admin=?",u.headquartersId(),u.employeeId()).isEmpty();}
|
||||
Map<String,Object> bulletins(LegacyUser u,boolean expired,int page) {
|
||||
u.require(18);page(page);return db.readTransaction(()->{
|
||||
String where=" WHERE t.tif_unerasable<>1 AND "+bulletinScope()+(expired?"":" AND t.tif_endtime>=?");
|
||||
var args=new ArrayList<Object>(List.of(u.employeeId(),u.employeeId(),u.headquartersId()));if(!expired)args.add(LocalDateTime.now());
|
||||
long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*) FROM "+ticker+" t"+where,Long.class,args.toArray())).orElse(0L);
|
||||
boolean admin=root(u);
|
||||
var rows=db.queryForList("SELECT t.*,CONCAT_WS(' ',su.usr_firstname,su.usr_name) AS sender,CONCAT_WS(' ',ru.usr_firstname,ru.usr_name) AS recipient,h.hq_mnemonic AS headquarters FROM "+ticker+" t LEFT JOIN employee se ON se.emp_id=t.emp_id_creator LEFT JOIN user su ON su.usr_id=se.usr_id LEFT JOIN employee re ON re.emp_id=t.emp_id LEFT JOIN user ru ON ru.usr_id=re.usr_id LEFT JOIN headquarters h ON h.hq_id=t.hq_id"+where+" ORDER BY t.tif_createtime DESC,t.tif_id DESC LIMIT 100 OFFSET "+page*100,args.toArray());
|
||||
return fields("total",total,"items",rows.stream().<Map<String,Object>>map(r->fields("id",id(r,"tif_id"),"created",dateTime(r,"tif_createtime"),"from",dateTime(r,"tif_starttime"),"to",dateTime(r,"tif_endtime"),"text",text(r,"tif_text"),"sender",text(r,"sender"),"senderId",id(r,"emp_id_creator"),"recipient",text(r,"recipient"),"headquarters",text(r,"headquarters"),"removable",id(r,"emp_id_creator")==u.employeeId()||admin,"revision",revision(bulletinVersion(r)))).toList());
|
||||
});
|
||||
}
|
||||
private static Map<String,Object> bulletinVersion(Map<String,Object> row){var v=new LinkedHashMap<String,Object>();for(String k:List.of("tif_id","hq_id","emp_id","emp_id_creator","tif_createtime","tif_starttime","tif_endtime","tif_subject","tif_text","tif_unerasable"))v.put(k,row.get(k));return v;}
|
||||
Map<String,Object> createBulletin(LegacyUser u,Bulletin b) {
|
||||
u.require(18);if(b==null||b.employee()<0||b.headquarters()<0)throw bad("Mitteilung oder Empfängerauswahl ungültig.");range(b.from(),b.to());String body=required(b.text(),limit(u,"NEWSTICKER_MAX_BODY_LENGTH",1000),"Mitteilung");
|
||||
if(b.to().isBefore(LocalDate.now()))throw bad("Das Enddatum darf nicht in der Vergangenheit liegen.");
|
||||
var allowed=headquarters(u).stream().map(r->id(r,"id")).toList();long hq=b.headquarters();
|
||||
if(b.employee()>0){var recipient=one(db.queryForList("SELECT u.hq_id FROM employee e JOIN user u ON u.usr_id=e.usr_id WHERE e.emp_id=? AND u.usr_type=1",b.employee()));if(!allowed.contains(id(recipient,"hq_id")))throw new AccessDeniedException("Empfänger nicht freigegeben.");hq=0;}
|
||||
else if(hq==0){u.require(10);if(allowed.isEmpty())throw bad("Keine Niederlassungen verfügbar.");}
|
||||
else if(!allowed.contains(hq))throw new AccessDeniedException("Niederlassung nicht freigegeben.");
|
||||
var targets=hq==0&&b.employee()==0?allowed:List.of(hq);return db.writeTransaction(()->{var ids=new ArrayList<Long>();for(long target:targets)ids.add(db.insert(ticker,fields("hq_id",target,"emp_id",b.employee(),"emp_id_creator",u.employeeId(),"tif_createtime",LocalDateTime.now(),"tif_starttime",b.from().isBefore(LocalDate.now())?LocalDateTime.now():b.from().atStartOfDay(),"tif_endtime",b.to().atTime(23,59,59),"tif_subject","","tif_text",body,"tif_unerasable",0)));return fields("ids",ids);});
|
||||
}
|
||||
void deleteBulletin(LegacyUser u,long id,String rev) {
|
||||
u.require(18);db.writeTransaction(()->{var row=one(db.lockRows("SELECT t.* FROM "+ticker+" t WHERE t.tif_id=? AND t.tif_unerasable<>1 AND "+bulletinScope(),id,u.employeeId(),u.employeeId(),u.headquartersId()));
|
||||
if(id(row,"emp_id_creator")!=u.employeeId()&&!root(u))throw new AccessDeniedException("Nur der Verfasser oder Niederlassungsadministrator darf diese Mitteilung entfernen.");unchanged(bulletinVersion(row),rev);db.update("DELETE FROM "+ticker+" WHERE tif_id=?",id);return null;});
|
||||
}
|
||||
static void page(int n){if(n<0||n>100000)throw bad("Ungültige Seite.");}
|
||||
Map<String,Object> history(LegacyUser u,Query q) {
|
||||
u.require(4);if(q==null)throw bad("Filter fehlen.");range(q.from(),q.to());page(q.page());
|
||||
if(q.states()==null||q.states().isEmpty()||q.states().size()>4||q.states().stream().anyMatch(v->v==null||v<0||v>3)||q.maxPriority()<0||q.maxPriority()>3||!List.of("ALL","EXCLUDE_HQ","ONLY_HQ").contains(q.direction()))throw bad("Ungültige Nachrichtenfilter.");
|
||||
var args=new ArrayList<Object>(List.of(u.headquartersId(),q.from().atStartOfDay(),q.to().plusDays(1).atStartOfDay(),q.maxPriority()));args.addAll(q.states());
|
||||
String where=" WHERE f.hq_id=? AND f.mf_createtime>=? AND f.mf_createtime<? AND f.mf_prio<=? AND f.mf_state IN ("+CrmScope.marks(q.states().size())+")";
|
||||
if(q.direction().equals("EXCLUDE_HQ"))where+=" AND f.mf_type<>1";if(q.direction().equals("ONLY_HQ"))where+=" AND f.mf_type=1";
|
||||
if(q.courier()!=null&&q.courier()>0){var c=one(db.queryForList("SELECT usr_id FROM courier WHERE cr_id=? AND hq_id=?",q.courier(),u.headquartersId()));where+=" AND (f.usr_id_sender=? OR f.usr_id_receiver=?)";args.add(id(c,"usr_id"));args.add(id(c,"usr_id"));}
|
||||
final String filter=where;return db.readTransaction(()->{
|
||||
long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*) FROM "+forum+" f"+filter,Long.class,args.toArray())).orElse(0L);
|
||||
var rows=db.queryForList("SELECT f.*,CONCAT_WS(' ',s.usr_firstname,s.usr_name) AS sender,CONCAT_WS(' ',r.usr_firstname,r.usr_name) AS recipient,CONCAT_WS(' ',rd.usr_firstname,rd.usr_name) AS reader,CONCAT_WS(' ',an.usr_firstname,an.usr_name) AS answerer FROM "+forum+" f LEFT JOIN user s ON s.usr_id=f.usr_id_sender LEFT JOIN user r ON r.usr_id=f.usr_id_receiver LEFT JOIN user rd ON rd.usr_id=f.usr_id_first_reader LEFT JOIN user an ON an.usr_id=f.usr_id_answerer"+filter+" ORDER BY f.mf_createtime DESC,f.mf_id DESC LIMIT 100 OFFSET "+q.page()*100,args.toArray());
|
||||
return fields("total",total,"items",rows.stream().<Map<String,Object>>map(r->fields("id",id(r,"mf_id"),"created",dateTime(r,"mf_createtime"),"delivery",dateTime(r,"mf_delivery"),"acknowledged",dateTime(r,"mf_acknowledge"),"state",id(r,"mf_state"),"priority",id(r,"mf_prio"),"sender",text(r,"sender"),"recipient",text(r,"recipient"),"reader",text(r,"reader"),"answerer",text(r,"answerer"),"subject",text(r,"mf_subject"),"body",text(r,"mf_body"),"replyTo",id(r,"mf_pre_id"),"canRead",id(r,"mf_usr_type")==3&&id(r,"mf_state")<2,"canReply",id(r,"mf_usr_type")==3&&id(r,"mf_state")<3,"revision",revision(messageVersion(r)))).toList());
|
||||
});
|
||||
}
|
||||
static Map<String,Object> messageVersion(Map<String,Object> r){var v=new TreeMap<String,Object>();r.forEach((k,val)->{if(k.startsWith("mf_")||k.startsWith("usr_id_")||k.equals("hq_id"))v.put(k,val);});return v;}
|
||||
Map<String,Object> incoming(LegacyUser u,long id){var row=one(db.queryForList("SELECT * FROM "+forum+" WHERE mf_id=? AND hq_id=? AND mf_usr_type=3",id,u.headquartersId()));if(id(row,"mf_state")>=3)throw bad("Nachricht wurde bereits beantwortet.");return row;}
|
||||
void acknowledge(LegacyUser u,long id,String rev){u.require(4);var row=incoming(u,id);unchanged(messageVersion(row),rev);if(id(row,"mf_state")>=2)throw bad("Nachricht wurde bereits gelesen.");db.writeTransaction(()->{if(db.update("UPDATE "+forum+" SET mf_state=2,mf_acknowledge=?,usr_id_first_reader=? WHERE mf_id=? AND hq_id=? AND mf_usr_type=3 AND mf_state=?",LocalDateTime.now(),u.userId(),id,u.headquartersId(),id(row,"mf_state"))!=1)throw bad("Die Nachricht wurde inzwischen gelesen. Bitte aktualisieren.");return null;});}
|
||||
List<Map<String,Object>> couriers(LegacyUser u,String search){u.require(4);if(search==null||search.length()>100)throw bad("Ungültiger Suchtext.");return db.queryForList("SELECT c.cr_id AS id,c.usr_id,CONCAT_WS(' · ',c.cr_sid,c.cr_eid,p.cmp_comp) AS label,c.cr_mobile_pda AS device,c.cr_availabletime AS available FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.hq_id=? AND p.cmp_archived=0 AND p.cmp_authenticated=1 AND (c.cr_sid LIKE ? OR c.cr_eid LIKE ? OR p.cmp_comp LIKE ?) ORDER BY label LIMIT 100",u.headquartersId(),"%"+search+"%","%"+search+"%","%"+search+"%");}
|
||||
List<Map<String,Object>> groups(LegacyUser u){u.require(4);return db.queryForList("SELECT msggrp_id,msggrp_name,msggrp_group,hq_id FROM messagegroup WHERE hq_id=? ORDER BY msggrp_name,msggrp_id",u.headquartersId()).stream().<Map<String,Object>>map(r->fields("id",id(r,"msggrp_id"),"name",text(r,"msggrp_name"),"groups",GroupAdministration.memberships(text(r,"msggrp_group")),"revision",revision(r))).toList();}
|
||||
List<Map<String,Object>> genericGroups(LegacyUser u){u.require(4);u.require(52);return db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name",mandator(db,u),u.headquartersId());}
|
||||
private Map<String,Object> group(LegacyUser u,long id,boolean lock){String sql="SELECT msggrp_id,msggrp_name,msggrp_group,hq_id FROM messagegroup WHERE msggrp_id=? AND hq_id=?";return one(lock?db.lockRows(sql,id,u.headquartersId()):db.queryForList(sql,id,u.headquartersId()));}
|
||||
void groupLog(LegacyUser u,long id,String name,String operation,String detail) {
|
||||
audit.audit(u,0,0,0,203,"MSGGRP_ID="+id+"|MSGGRP_NAME="+name.replace("|","/")+"|OP="+operation+detail,UUID.randomUUID().toString());
|
||||
}
|
||||
Map<String,Object> saveGroup(LegacyUser u,long id,GroupSave s) {
|
||||
u.require(4);u.require(52);
|
||||
if(s==null||s.groups()==null||s.groups().size()>100||s.groups().stream().anyMatch(Objects::isNull))throw bad("Ungültige Gruppen.");
|
||||
String name=required(s.name(),100,"Bezeichnung");var allowed=genericGroups(u).stream().map(r->id(r,"id")).toList();
|
||||
if(!allowed.containsAll(s.groups()))throw bad("Eine zugeordnete Gruppe ist nicht freigegeben.");
|
||||
String joined=String.join(",",s.groups().stream().distinct().sorted().map(String::valueOf).toList());if(joined.length()>255)throw bad("Zu viele zugeordnete Gruppen.");
|
||||
return db.writeTransaction(()->{
|
||||
lockHeadquarters(db,u);var before=id>0?group(u,id,true):Map.<String,Object>of();if(id>0)unchanged(before,s.revision());
|
||||
if(!db.queryForList("SELECT msggrp_id FROM messagegroup WHERE hq_id=? AND msggrp_name=? AND msggrp_id<>?",u.headquartersId(),name,id).isEmpty())throw bad("Nachrichtengruppe existiert bereits.");
|
||||
long saved=id;if(id==0)saved=db.insert("messagegroup",fields("hq_id",u.headquartersId(),"msggrp_name",name,"msggrp_group",joined));else db.update("UPDATE messagegroup SET msggrp_name=?,msggrp_group=? WHERE msggrp_id=? AND hq_id=?",name,joined,id,u.headquartersId());
|
||||
if(id==0||!text(before,"msggrp_name").equals(name))groupLog(u,saved,name,id==0?"INSERT":"UPDATE","");
|
||||
var old=GroupAdministration.memberships(text(before,"msggrp_group"));var next=new TreeSet<>(s.groups());
|
||||
for(long g:next)if(!old.contains(g))groupLog(u,saved,name,"ADD_GROUP","|GRP_ID="+g);
|
||||
for(long g:old)if(!next.contains(g))groupLog(u,saved,name,"REMOVE_GROUP","|GRP_ID="+g);
|
||||
return fields("id",saved);
|
||||
});
|
||||
}
|
||||
void deleteGroup(LegacyUser u,long id,String rev){u.require(4);u.require(52);db.writeTransaction(()->{lockHeadquarters(db,u);var old=group(u,id,true);unchanged(old,rev);var rows=db.lockRows("SELECT cr_id,cr_msggrp,hq_id FROM courier WHERE cr_msggrp LIKE ?","%,"+id+",%");if(rows.stream().anyMatch(r->id(r,"hq_id")!=u.headquartersId()))throw bad("Die Gruppe hat fremde Niederlassungszuordnungen. Diese müssen zuerst bereinigt werden.");for(var row:rows){var members=GroupAdministration.memberships(text(row,"cr_msggrp"));members.remove(id);db.update("UPDATE courier SET cr_msggrp=? WHERE cr_id=?",comma(members),id(row,"cr_id"));}db.update("DELETE FROM messagegroup WHERE msggrp_id=? AND hq_id=?",id,u.headquartersId());groupLog(u,id,text(old,"msggrp_name"),"REMOVE","");return null;});}
|
||||
static String comma(Collection<Long> ids){return ids.isEmpty()?"":","+String.join(",",ids.stream().sorted().map(String::valueOf).toList())+",";}
|
||||
List<Map<String,Object>> groupMembers(LegacyUser u,long group){u.require(4);u.require(52);group(u,group,false);return db.queryForList("SELECT c.cr_id AS id,CONCAT_WS(' · ',c.cr_sid,c.cr_eid,p.cmp_comp) AS label,c.cr_msggrp FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.hq_id=? ORDER BY label",u.headquartersId()).stream().<Map<String,Object>>map(r->fields("id",id(r,"id"),"label",text(r,"label"),"member",GroupAdministration.memberships(text(r,"cr_msggrp")).contains(group),"revision",revision(r))).toList();}
|
||||
void membership(LegacyUser u,long group,long courier,String rev,boolean member){u.require(4);u.require(52);db.writeTransaction(()->{lockHeadquarters(db,u);group(u,group,true);var row=one(db.lockRows("SELECT c.cr_id AS id,CONCAT_WS(' · ',c.cr_sid,c.cr_eid,p.cmp_comp) AS label,c.cr_msggrp FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.hq_id=? AND c.cr_id=?",u.headquartersId(),courier));unchanged(row,rev);var ids=GroupAdministration.memberships(text(row,"cr_msggrp"));if(member)ids.add(group);else ids.remove(group);String value=comma(ids);if(value.length()>255)throw bad("Zu viele Nachrichtengruppen.");db.update("UPDATE courier SET cr_msggrp=? WHERE cr_id=? AND hq_id=?",value,courier,u.headquartersId());if(!text(row,"cr_msggrp").equals(value))groupLog(u,group,"EMPTY",member?"ADD_COURIER":"REMOVE_COURIER","|CR_ID="+courier);return null;});}
|
||||
private List<Map<String,Object>> recipients(LegacyUser u,Dispatch d) {
|
||||
var unique=new TreeMap<Long,Map<String,Object>>();for(var row:recipientRows(u,d)){if(id(row,"id")<=0)throw bad("Empfänger hat keine gültige Benutzerkennung.");unique.putIfAbsent(id(row,"id"),row);}return List.copyOf(unique.values());
|
||||
}
|
||||
private List<Map<String,Object>> recipientRows(LegacyUser u,Dispatch d) {
|
||||
if(d.replyTo()!=null&&d.replyTo()>0){var row=incoming(u,d.replyTo());unchanged(messageVersion(row),d.revision());return db.queryForList("SELECT c.usr_id AS id,CONCAT_WS(' · ',c.cr_sid,p.cmp_comp) AS label FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.usr_id=? AND c.hq_id=?",id(row,"usr_id_sender"),u.headquartersId());}
|
||||
if(d.courier()!=null&&d.courier()>0)return db.queryForList("SELECT c.usr_id AS id,CONCAT_WS(' · ',c.cr_sid,p.cmp_comp) AS label FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE c.cr_id=? AND c.hq_id=? AND p.cmp_archived=0 AND p.cmp_authenticated=1",d.courier(),u.headquartersId());
|
||||
var args=new ArrayList<Object>();String scope;
|
||||
if(d.allActive()){u.require(52);u.require(10);var h=headquarters(u).stream().map(r->id(r,"id")).toList();if(h.isEmpty())throw bad("Keine Niederlassungen verfügbar.");scope="c.hq_id IN ("+CrmScope.marks(h.size())+") AND c.cr_availabletime>?";args.addAll(h);var par=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=0 AND emp_id=0 AND par_key='MESSAGE_MIN_DAYS_BEFORE'");int days=7;try{if(par.size()==1)days=Integer.parseInt(text(par.getFirst(),"par_value"));}catch(NumberFormatException ignored){}if(days<1||days>365)days=7;args.add(LocalDateTime.now().minusDays(days));}
|
||||
else {if(d.groups()==null||d.groups().isEmpty()||d.groups().size()>100||d.groups().stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine Nachrichtengruppe oder einen Transporteur auswählen.");var generic=new TreeSet<Long>();for(long g:d.groups())generic.addAll(GroupAdministration.memberships(text(group(u,g,false),"msggrp_group")));
|
||||
// Generic group membership is transitive; a child's grp_group contains its parents.
|
||||
var tree=db.queryForList("SELECT grp_id,grp_group FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0)",mandator(db,u),u.headquartersId());var allowed=tree.stream().map(r->id(r,"grp_id")).toList();if(!allowed.containsAll(generic))throw bad("Nachrichtengruppe enthält nicht freigegebene Gruppen.");boolean changed=true;while(changed){changed=false;for(var g:tree)if(!Collections.disjoint(generic,GroupAdministration.memberships(text(g,"grp_group"))))changed|=generic.add(id(g,"grp_id"));}
|
||||
scope="c.hq_id=? AND (";args.add(u.headquartersId());var predicates=new ArrayList<String>();for(long g:new TreeSet<>(d.groups())){predicates.add("c.cr_msggrp LIKE ?");args.add("%,"+g+",%");}for(long g:generic){predicates.add("c.cr_group LIKE ?");args.add("%,"+g+",%");}scope+=String.join(" OR ",predicates)+")";
|
||||
}
|
||||
return db.queryForList("SELECT DISTINCT c.usr_id AS id,CONCAT_WS(' · ',c.cr_sid,p.cmp_comp) AS label FROM courier c JOIN company p ON p.cmp_id=c.cmp_id WHERE "+scope+" AND p.cmp_archived=0 AND p.cmp_authenticated=1 ORDER BY id LIMIT 5001",args.toArray());
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser u,Dispatch d) {
|
||||
u.require(4);if(d==null)throw bad("Nachricht fehlt.");String subject=d.subject()==null?"":d.subject().strip();if(subject.length()>40)throw bad("Betreff darf höchstens 40 Zeichen enthalten.");String body=required(d.body(),limit(u,"MESSAGE_MAX_BODY_LENGTH",250),"Nachricht");
|
||||
if((d.replyTo()!=null&&d.replyTo()>0?1:0)+(d.courier()!=null&&d.courier()>0?1:0)+(d.groups()!=null&&!d.groups().isEmpty()?1:0)+(d.allActive()?1:0)!=1)throw bad("Bitte genau eine Empfängerauswahl verwenden.");
|
||||
var rows=recipients(u,d);if(rows.isEmpty()||rows.size()>5000)throw bad("Die Auswahl muss 1 bis 5000 Empfänger enthalten.");
|
||||
var p=new Pending();p.user=u.userId();p.hq=u.headquartersId();p.draft=d;p.reply=d.replyTo()==null?0:d.replyTo();p.subject=subject;p.body=body;p.recipients=rows;p.recipientRevision=revision(rows);p.type=d.allActive()||d.groups()!=null&&!d.groups().isEmpty()?1:0;p.expires=Instant.now().plusSeconds(900);
|
||||
String token=UUID.randomUUID().toString();synchronized(previews){previews.values().removeIf(v->v.expires.isBefore(Instant.now())&&!v.sending);if(previews.size()>=1000)throw bad("Zu viele Versandvorschauen.");previews.put(token,p);}return fields("token",token,"recipients",rows,"subject",subject,"body",body,"replyTo",p.reply);
|
||||
}
|
||||
Map<String,Object> send(LegacyUser u,String token) {
|
||||
u.require(4);Pending p;synchronized(previews){p=previews.get(token);if(p==null||p.user!=u.userId()||p.hq!=u.headquartersId()||p.expires.isBefore(Instant.now()))throw bad("Vorschau abgelaufen. Bitte neu erstellen.");if(p.result!=null)return p.result;if(p.sending)throw bad("Versand läuft bereits.");unchanged(recipients(u,p.draft),p.recipientRevision);if(p.reply>0){int claimed=db.writeTransaction(()->db.update("UPDATE "+forum+" SET mf_state=3,usr_id_answerer=?,mf_acknowledge=? WHERE mf_id=? AND hq_id=? AND mf_usr_type=3 AND mf_state<3",u.userId(),LocalDateTime.now(),p.reply,u.headquartersId()));if(claimed!=1)throw bad("Diese Nachricht wurde zwischenzeitlich beantwortet. Bitte Historie neu laden.");}if(p.draft.allActive())db.writeTransaction(()->{audit.audit(u,0,0,0,203,"MSGGRP_ID=ALL|MSGGRP_NAME=ALL|OP=SEND_TO_ALL|RECIPIENTS="+p.recipients.size(),token);return null;});p.sending=true;}
|
||||
var sent=new ArrayList<Map<String,Object>>();String warning="";int uncertain=0;
|
||||
try {
|
||||
for(var r:p.recipients){long message=db.writeTransaction(()->db.insert(forum,fields("hq_id",u.headquartersId(),"mf_type",p.type,"mf_createtime",LocalDateTime.now(),"usr_id_sender",u.userId(),"usr_id_receiver",id(r,"id"),"mf_usr_type",1,"mf_subject",p.subject,"mf_body",p.body,"mf_pre_id",p.reply)));sent.add(fields("recipient",id(r,"id"),"message",message));}
|
||||
}catch(RuntimeException e){uncertain=1;warning="Die Übergabe wurde unterbrochen. Der nächste Empfänger könnte die Nachricht bereits erhalten haben. Bitte die Historie prüfen; es erfolgt keine automatische Wiederholung.";}
|
||||
if(p.reply>0&&uncertain>0)warning+=" Die Ursprungsnachricht ist zur Vermeidung doppelter Antworten als beantwortet markiert; die tatsächliche Antwortübergabe in der Historie prüfen.";
|
||||
var result=fields("queued",sent.size(),"total",p.recipients.size(),"uncertain",uncertain,"unattempted",p.recipients.size()-sent.size()-uncertain,"messages",sent,"warning",warning);
|
||||
synchronized(previews){p.result=result;p.sending=false;}return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
class MessageController {
|
||||
private final MessageAdministration service;
|
||||
MessageController(MessageAdministration service){this.service=service;}
|
||||
record Revision(String revision) {}
|
||||
record Membership(String revision,boolean member) {}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication a){return service.options(user(a));}
|
||||
@GetMapping("/bulletins") Map<String,Object> bulletins(Authentication a,@RequestParam(defaultValue="false") boolean expired,@RequestParam(defaultValue="0") int page){return service.bulletins(user(a),expired,page);}
|
||||
@PostMapping("/bulletins") Map<String,Object> create(Authentication a,@RequestBody MessageAdministration.Bulletin b){return service.createBulletin(user(a),b);}
|
||||
@DeleteMapping("/bulletins/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication a,@PathVariable long id,@RequestBody Revision r){service.deleteBulletin(user(a),id,r.revision());}
|
||||
@PostMapping("/history") Map<String,Object> history(Authentication a,@RequestBody MessageAdministration.Query q){return service.history(user(a),q);}
|
||||
@PostMapping("/{id}/read") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void read(Authentication a,@PathVariable long id,@RequestBody Revision r){service.acknowledge(user(a),id,r.revision());}
|
||||
@GetMapping("/couriers") List<Map<String,Object>> couriers(Authentication a,@RequestParam(defaultValue="") String q){return service.couriers(user(a),q);}
|
||||
@GetMapping("/groups") List<Map<String,Object>> groups(Authentication a){return service.groups(user(a));}
|
||||
@GetMapping("/generic-groups") List<Map<String,Object>> generic(Authentication a){return service.genericGroups(user(a));}
|
||||
@PostMapping("/groups") Map<String,Object> group(Authentication a,@RequestBody MessageAdministration.GroupSave r){return service.saveGroup(user(a),0,r);}
|
||||
@PutMapping("/groups/{id}") Map<String,Object> group(Authentication a,@PathVariable long id,@RequestBody MessageAdministration.GroupSave r){return service.saveGroup(user(a),id,r);}
|
||||
@DeleteMapping("/groups/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void group(Authentication a,@PathVariable long id,@RequestBody Revision r){service.deleteGroup(user(a),id,r.revision());}
|
||||
@GetMapping("/groups/{id}/members") List<Map<String,Object>> members(Authentication a,@PathVariable long id){return service.groupMembers(user(a),id);}
|
||||
@PutMapping("/groups/{id}/members/{courier}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void member(Authentication a,@PathVariable long id,@PathVariable long courier,@RequestBody Membership r){service.membership(user(a),id,courier,r.revision(),r.member());}
|
||||
@PostMapping("/preview") Map<String,Object> preview(Authentication a,@RequestBody MessageAdministration.Dispatch d){return service.preview(user(a),d);}
|
||||
@PostMapping("/send/{token}") Map<String,Object> send(Authentication a,@PathVariable String token){return service.send(user(a),token);}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.util.*;
|
||||
import java.time.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CrmScope.marks;
|
||||
|
||||
/** Category-scoped EAV records shared by native editors; all writes retain PHP field IDs. */
|
||||
abstract class MetafieldRecords {
|
||||
final DatabaseService db;final FormAdministration forms;final MessageAdministration messages;
|
||||
final long category;
|
||||
MetafieldRecords(DatabaseService db,FormAdministration forms,MessageAdministration messages,long category){this.db=db;this.forms=forms;this.messages=messages;this.category=category;}
|
||||
record Query(List<Long> headquarters,String search,boolean prefix,int page){}
|
||||
record Save(long headquarters,String revision,String schemaRevision,Map<Long,String> values){}
|
||||
abstract void access(LegacyUser u);
|
||||
abstract String validateValue(LegacyUser u,long hq,Map<String,Object> def,String value);
|
||||
void validateValues(List<Map<String,Object>> defs,Map<Long,String> values){}
|
||||
List<Map<String,Object>> headquarters(LegacyUser u){access(u);return messages.headquarters(u);}
|
||||
long checked(LegacyUser u,long hq){access(u);if(headquarters(u).stream().noneMatch(r->id(r,"id")==hq))throw new AccessDeniedException("Niederlassung nicht freigegeben.");return forms.fieldHeadquarters(u,category)==0?0:hq;}
|
||||
static String definitionsSql(long category){return "SELECT ck.mtfck_id AS id,ck.mtfk_id AS keyId,ck.mtfck_history AS history,k.mtfk_name AS label,k.mtfk_type AS type FROM metafieldcategorykey ck JOIN metafieldkey k ON k.mtfk_id=ck.mtfk_id WHERE ck.mtfc_id="+category+" AND ck.hq_id=? ORDER BY ck.mtfck_sort,ck.mtfck_id";}
|
||||
List<Map<String,Object>> definitions(long hq){var rows=db.queryForList(definitionsSql(category),hq);if(rows.isEmpty())throw bad("Keine Formularfelder für diese Niederlassung eingerichtet.");return rows;}
|
||||
Map<String,Object> schema(LegacyUser u,long hq){long scope=checked(u,hq);var defs=definitions(scope);return fields("fields",defs,"headquarters",scope,"revision",revision(defs));}
|
||||
List<Map<String,Object>> raw(long object,List<Map<String,Object>> defs){var args=new ArrayList<Object>(List.of(object));args.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());}
|
||||
static Map<Long,String> values(List<Map<String,Object>> rows){var result=new LinkedHashMap<Long,String>();for(var r:rows)if(result.put(id(r,"mtfck_id"),text(r,"mtfv_value"))!=null)throw bad("Doppelte Formularwerte im Bestand. Bitte zuerst bereinigen.");return result;}
|
||||
Map<String,Object> get(LegacyUser u,long hq,long object){long scope=checked(u,hq);var defs=definitions(scope);var rows=raw(object,defs);if(rows.isEmpty())one(rows);return fields("id",object,"headquarters",hq,"values",values(rows),"revision",revision(rows),"schema",fields("fields",defs,"headquarters",scope,"revision",revision(defs)));}
|
||||
Map<String,Object> query(LegacyUser u,Query q){access(u);if(q==null||q.headquarters()==null||q.headquarters().isEmpty()||q.headquarters().size()>100||q.headquarters().stream().anyMatch(Objects::isNull))throw bad("Bitte Niederlassungen auswählen.");MessageAdministration.page(q.page());String search=q.search()==null?"":q.search();if(search.length()>100)throw bad("Suchbegriff ist zu lang.");var scopes=new TreeSet<Long>();for(long h:q.headquarters())scopes.add(checked(u,h));var args=new ArrayList<Object>(scopes);String where="ck.mtfc_id="+category+" AND ck.hq_id IN ("+marks(scopes.size())+")";if(!search.isBlank()){where+=" AND v.mtfv_value LIKE ?";args.add((q.prefix()?"":"%")+search.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%");}
|
||||
String from=" FROM metafieldvalue v JOIN metafieldcategorykey ck ON ck.mtfck_id=v.mtfck_id WHERE "+where;long total=Optional.ofNullable(db.queryForObject("SELECT COUNT(*) FROM (SELECT DISTINCT ck.hq_id,v.mtfv_id"+from+") matches",Long.class,args.toArray())).orElse(0L);var items=db.queryForList("SELECT DISTINCT ck.hq_id AS headquarters,v.mtfv_id AS id"+from+" ORDER BY v.mtfv_id DESC,ck.hq_id LIMIT 50 OFFSET "+q.page()*50,args.toArray());var result=new ArrayList<Map<String,Object>>();for(var item:items){long hq=id(item,"headquarters");var defs=definitions(hq);var vals=values(raw(id(item,"id"),defs));result.add(fields("id",id(item,"id"),"headquarters",hq==0?u.headquartersId():hq,"global",hq==0,"fields",defs.stream().map(d->fields("label",text(d,"label"),"type",text(d,"type"),"value",vals.getOrDefault(id(d,"id"),""))).toList()));}return fields("items",result,"total",total);
|
||||
}
|
||||
Map<String,Object> save(LegacyUser u,Long object,Save s){access(u);if(s==null||s.values()==null||s.values().size()>1000)throw bad("Formulardaten fehlen.");long scope=checked(u,s.headquarters());var defs=definitions(scope);unchanged(defs,s.schemaRevision());var keys=defs.stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld.");var old=object==null?List.<Map<String,Object>>of():raw(object,defs);if(object!=null){if(old.isEmpty())one(old);unchanged(old,s.revision());}var before=values(old);var next=new LinkedHashMap<Long,String>();for(var def:defs){long key=id(def,"id");String type=text(def,"type"),v=Objects.requireNonNullElse(s.values().getOrDefault(key,before.getOrDefault(key,"")),"");if(v.getBytes(StandardCharsets.UTF_8).length>65535)throw bad(text(def,"label")+": Feld ist zu lang.");if(!v.equals(before.getOrDefault(key,""))){v=validateValue(u,s.headquarters(),def,v);}next.put(key,v);}if(next.values().stream().allMatch(String::isBlank))throw bad("Bitte mindestens ein Formularfeld ausfüllen.");
|
||||
validateValues(defs,next);
|
||||
return db.contactWriteTransaction(()->{unchanged(definitions(scope),s.schemaRevision());var current=object==null?List.<Map<String,Object>>of():raw(object,defs);if(object!=null){if(current.isEmpty())one(current);unchanged(current,s.revision());}long saved=object==null?db.queryForObject("SELECT COALESCE(MAX(mtfv_id),0)+1 FROM metafieldvalue",Long.class):object;if(saved<=0||saved>Integer.MAX_VALUE)throw bad("Formular-ID-Bereich ausgeschöpft.");if(object!=null){var a=new ArrayList<Object>(List.of(saved));a.addAll(keys);db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(keys.size())+")",a.toArray());}for(var def:defs){long key=id(def,"id");String value=next.get(key);db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,saved,value);if(id(def,"history")==1){String previous=db.queryForList("SELECT mtfv_value FROM metafieldvaluehistory WHERE mtfck_id=? AND mtfv_id=? ORDER BY mtfvh_createtime DESC LIMIT 1",key,saved).stream().map(r->text(r,"mtfv_value")).findFirst().orElse("");if(!previous.equals(value))db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,saved,value,LocalDateTime.now(ZoneId.of("Europe/Berlin")));}}return fields("id",saved);});
|
||||
}
|
||||
void remove(LegacyUser u,long hq,long object,String rev){long scope=checked(u,hq);var defs=definitions(scope);db.contactWriteTransaction(()->{unchanged(definitions(scope),revision(defs));var old=raw(object,defs);if(old.isEmpty())one(old);unchanged(old,rev);var a=new ArrayList<Object>(List.of(object));a.addAll(defs.stream().map(d->id(d,"id")).toList());db.update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+")",a.toArray());return null;});}
|
||||
List<Map<String,Object>> history(LegacyUser u,long hq,long object){get(u,hq,object);var defs=definitions(checked(u,hq));var a=new ArrayList<Object>(List.of(object));a.addAll(defs.stream().map(d->id(d,"id")).toList());return db.queryForList("SELECT mtfck_id AS fieldId,mtfv_value AS value,mtfvh_createtime AS time FROM metafieldvaluehistory WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfvh_createtime DESC LIMIT 1000",a.toArray());}
|
||||
Map<String,Object> rendering(LegacyUser u,long hq,long object){var data=get(u,hq,object);var row=one(db.queryForList("SELECT c.mtft_id,t.mtft_content FROM metafieldcategory c LEFT JOIN metafieldtemplate t ON t.mtft_id=c.mtft_id WHERE c.mtfc_id=?",category));var defs=definitions(checked(u,hq));return fields("data",data,"template",text(row,"mtft_content"),"definitions",defs,"revision",revision(Arrays.asList(data.get("revision"),data.get("schema"),row)));}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ public final class Models {
|
||||
public enum CourierStatus { AVAILABLE, BUSY, OFFLINE }
|
||||
public enum CancelMode { FREE, CHARGEABLE }
|
||||
public static class Customer {
|
||||
public Long id, version;
|
||||
public Long id, version, costCenterId;
|
||||
public String costCenterName;
|
||||
public String number, name, street, postalCode, city, contact, phone, email;
|
||||
public Integer paymentDays;
|
||||
public String paymentTermLabel;
|
||||
public boolean active;
|
||||
}
|
||||
public static class Courier {
|
||||
@@ -57,5 +59,27 @@ public final class Models {
|
||||
public String action, detail, actor;
|
||||
}
|
||||
public record Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
|
||||
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders) {}
|
||||
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders,
|
||||
boolean createOrders, boolean readStatistics, boolean readAppointments, boolean readMessages, boolean manageForms) {
|
||||
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
|
||||
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders, boolean readStatistics, boolean readAppointments, boolean readMessages) {
|
||||
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,readAppointments,readMessages,false);
|
||||
}
|
||||
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
|
||||
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders, boolean readStatistics, boolean readAppointments) {
|
||||
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,readAppointments,false);
|
||||
}
|
||||
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
|
||||
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders, boolean readStatistics) {
|
||||
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,readStatistics,false);
|
||||
}
|
||||
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
|
||||
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders, boolean createOrders) {
|
||||
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,createOrders,false);
|
||||
}
|
||||
public Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
|
||||
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders) {
|
||||
this(readOrders,readCustomers,readCouriers,readInvoices,writeOrders,writeCustomers,writeCouriers,writeInvoices,completeStops,exportOrders,false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
@RestController @RequestMapping("/api/newsletters")
|
||||
class NewsletterController {
|
||||
final NewsletterService service;final NewsletterTemplate templates;final NewsletterImages images;final NewsletterDispatch dispatch;
|
||||
NewsletterController(NewsletterService service,NewsletterTemplate templates,NewsletterImages images,NewsletterDispatch dispatch){this.service=service;this.templates=templates;this.images=images;this.dispatch=dispatch;}
|
||||
@GetMapping("/mail/options") Map<String,Object> mailOptions(Authentication a){return dispatch.options(user(a));}
|
||||
@PostMapping("/{id}/mail/preview") Map<String,Object> mailPreview(Authentication a,@PathVariable long id,@RequestBody NewsletterDispatch.Draft d){return dispatch.preview(user(a),id,d);}
|
||||
@PostMapping("/mail/{token}/send") Map<String,Object> mailSend(Authentication a,@PathVariable String token){return dispatch.send(user(a),token);}
|
||||
@GetMapping("/mail/{token}") Map<String,Object> mailStatus(Authentication a,@PathVariable String token){return dispatch.status(user(a),token);}
|
||||
record Revision(String revision){}
|
||||
record ImageUpload(String data){}
|
||||
@PostMapping("/images") Map<String,Object> upload(Authentication a,@RequestBody ImageUpload s){return fields("filename",images.upload(user(a),s.data()));}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication a){var u=user(a);return fields("headquarters",service.headquarters(u),"currentHeadquarters",u.headquartersId());}
|
||||
@GetMapping("/schema") Map<String,Object> schema(Authentication a,@RequestParam long headquarters){return service.schema(user(a),headquarters);}
|
||||
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody NewsletterService.Query q){return service.query(user(a),q);}
|
||||
@GetMapping("/{id}") Map<String,Object> get(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.get(user(a),headquarters,id);}
|
||||
@PostMapping Map<String,Object> create(Authentication a,@RequestBody NewsletterService.Save s){return service.save(user(a),null,s);}
|
||||
@PutMapping("/{id}") Map<String,Object> save(Authentication a,@PathVariable long id,@RequestBody NewsletterService.Save s){return service.save(user(a),id,s);}
|
||||
@DeleteMapping("/{id}") void remove(Authentication a,@PathVariable long id,@RequestParam long headquarters,@RequestBody Revision r){service.remove(user(a),headquarters,id,r.revision());}
|
||||
@GetMapping("/{id}/history") List<Map<String,Object>> history(Authentication a,@PathVariable long id,@RequestParam long headquarters){return service.history(user(a),headquarters,id);}
|
||||
@GetMapping("/{id}/preview") Map<String,Object> preview(Authentication a,@PathVariable long id,@RequestParam long headquarters){var data=service.rendering(user(a),headquarters,id);return fields("html",NewsletterDispatch.previewHtml(templates.render(data)),"revision",data.get("revision"));}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CrmScope.marks;
|
||||
|
||||
@Service
|
||||
class NewsletterDispatch {
|
||||
record Draft(long headquarters,String mode,List<Long> headquartersSelection,String external,List<Integer> customerTypes,List<Long> branches,List<Long> groups,String cc,String bcc,boolean copiesEveryMail,String subject){}
|
||||
record Recipient(String address,String type,long id,String label,String hash){}
|
||||
static final String HASH="NG_NEWSLETTER_PERSONAL_HASH";
|
||||
final DatabaseService db;final MetafieldRecords newsletters;final NewsletterTemplate templates;final AppointmentMailTransport transport;
|
||||
final ExecutorService executor=Executors.newSingleThreadExecutor(r->{Thread t=new Thread(r,"newsletter-delivery");t.setDaemon(true);return t;});
|
||||
final Map<String,Pending> pending=new LinkedHashMap<>();
|
||||
static class Pending {LegacyUser actor;long object;Draft draft;String revision,recipientRevision,from,reply,html;List<Recipient> recipients;List<String> cc,bcc;volatile Instant expires;int sent,uncertain;volatile boolean started,running;String warning="";}
|
||||
@Autowired NewsletterDispatch(DatabaseService db,NewsletterService newsletters,NewsletterTemplate templates,AppointmentMailTransport transport){this(db,(MetafieldRecords)newsletters,templates,transport);}
|
||||
NewsletterDispatch(DatabaseService db,MetafieldRecords newsletters,NewsletterTemplate templates,AppointmentMailTransport transport){this.db=db;this.newsletters=newsletters;this.templates=templates;this.transport=transport;}
|
||||
@PreDestroy void shutdown(){executor.shutdownNow();}
|
||||
String parameter(long hq,String key){var rows=db.queryForList("SELECT par_value FROM parameter WHERE hq_id=? AND emp_id=0 AND par_key=?",hq,key);if(rows.size()>1)throw bad("Mehrdeutige Mail-Einstellung: "+key);return rows.isEmpty()?"":text(rows.getFirst(),"par_value");}
|
||||
Map<String,Object> options(LegacyUser u){newsletters.access(u);return fields("jobs",jobs(u),"bulk",u.hasRight(20),"headquarters",newsletters.headquarters(u),"branches",u.hasRight(20)?db.queryForList("SELECT br_id AS id,br_name AS label FROM branch ORDER BY br_name"):List.of(),"groups",u.hasRight(20)?db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=0 OR hq_id IN (SELECT hq_id FROM mandatorheadquarters WHERE md_id=?))"+(u.hasRight(10)?"":" AND (hq_id=0 OR hq_id="+u.headquartersId()+")")+" ORDER BY grp_name",mandator(db,u),mandator(db,u)):List.of());}
|
||||
List<Map<String,Object>> jobs(LegacyUser u){var result=new ArrayList<Map<String,Object>>();synchronized(pending){for(var e:pending.entrySet()){var p=e.getValue();if(p.actor.userId()==u.userId()&&p.actor.headquartersId()==u.headquartersId()&&p.started){synchronized(p){result.add(fields("token",e.getKey(),"newsletter",p.object,"subject",p.draft.subject(),"status",status(p)));}}}}return result;}
|
||||
static List<String> addresses(String raw){if(raw==null||raw.isBlank())return List.of();if(raw.length()>10000)throw bad("Zu viele E-Mail-Adressen.");var result=new TreeMap<String,String>();for(String item:raw.split("[,;]",-1)){String a=AppointmentNotifications.address(item);result.put(a.toLowerCase(Locale.ROOT),a);}if(result.size()>100)throw bad("Höchstens 100 manuelle Adressen sind erlaubt.");return List.copyOf(result.values());}
|
||||
List<Recipient> recipients(LegacyUser u,Draft d,boolean needHash){newsletters.access(u);if(d==null||d.mode()==null||!Set.of("manual","customers","groups").contains(d.mode()))throw bad("Empfängerauswahl fehlt.");var result=new ArrayList<Recipient>();
|
||||
if(d.mode().equals("manual")){for(String address:addresses(d.external()))result.add(new Recipient(address,"",0,address,""));if(needHash)throw bad("Diese Vorlage enthält persönliche Abmeldelinks. Dafür Kunden oder Gruppen auswählen; eine manuelle Adresse hat keinen zugeordneten Abmeldeschlüssel.");}
|
||||
else {u.require(20);if(d.headquartersSelection()==null||d.headquartersSelection().isEmpty()||d.headquartersSelection().size()>100||d.headquartersSelection().stream().anyMatch(Objects::isNull))throw bad("Bitte Empfänger-Niederlassungen auswählen.");var hqs=d.headquartersSelection().stream().distinct().sorted().toList();for(long hq:hqs)newsletters.checked(u,hq);var args=new ArrayList<Object>(hqs);String where="cs.hq_id IN ("+marks(hqs.size())+") AND cmp.cmp_newsletter=1"+(parameter(u.headquartersId(),"MAIL_NEWSLETTER_DSGVO_DISABLED").equals("1")?"":" AND cmp.cmp_dsgvo=1");
|
||||
if(d.mode().equals("customers")){if(d.customerTypes()==null||d.customerTypes().isEmpty()||d.customerTypes().size()>4||d.customerTypes().stream().anyMatch(t->t==null||t<0||t>3))throw bad("Bitte Kundentypen auswählen.");where+=" AND cmp.cmp_type IN ("+marks(d.customerTypes().size())+")";args.addAll(d.customerTypes());if(d.branches()!=null&&!d.branches().isEmpty()){if(d.branches().size()>500||d.branches().stream().anyMatch(b->b==null||b<=0))throw bad("Ungültige Branchen.");where+=" AND cmp.br_id IN ("+marks(d.branches().size())+")";args.addAll(d.branches());}}
|
||||
else {if(d.groups()==null||d.groups().isEmpty()||d.groups().size()>100||d.groups().stream().anyMatch(g->g==null||g<=0))throw bad("Bitte Gruppen auswählen.");var valid=db.queryForList("SELECT grp_id FROM groups WHERE md_id=? AND (hq_id=0 OR hq_id IN ("+marks(hqs.size())+"))",concat(List.of(mandator(db,u)),hqs)).stream().map(r->id(r,"grp_id")).toList();if(!valid.containsAll(d.groups()))throw bad("Empfängergruppe ist nicht freigegeben.");where+=" AND ("+groupWhere("cs.cs_group",d.groups().size())+")";args.addAll(d.groups().stream().map(g->"%,"+g+",%").toList());}
|
||||
String sql="SELECT DISTINCT cs.cs_id AS id,'cs' AS type,CONCAT_WS(' ',cs.cs_eid,cmp.cmp_comp,cmp.cmp_comp2) AS label,CASE WHEN cs.cs_jbstatusmail2csc=1 THEN ca.cscad_email ELSE usr.usr_email END AS email FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN employee emp ON emp.emp_id=cs.cs_admin LEFT JOIN user usr ON usr.usr_id=emp.usr_id LEFT JOIN costcenter csc ON csc.cs_id=cs.cs_id AND cs.cs_jbstatusmail2csc=1 LEFT JOIN costcenteraddress ca ON ca.csc_id=csc.csc_id AND ca.adt_id=2 WHERE "+where+" ORDER BY cs.cs_id,email LIMIT 5001";
|
||||
addRows(result,db.queryForList(sql,args.toArray()));
|
||||
if(d.mode().equals("groups")){var p=new ArrayList<Object>(hqs);p.addAll(d.groups().stream().map(g->"%,"+g+",%").toList());String consent=" AND cmp.cmp_newsletter=1"+(parameter(u.headquartersId(),"MAIL_NEWSLETTER_DSGVO_DISABLED").equals("1")?"":" AND cmp.cmp_dsgvo=1");
|
||||
addRows(result,db.queryForList("SELECT DISTINCT cr.cr_id AS id,'cr' AS type,CONCAT_WS(' ',cr.cr_eid,cmp.cmp_comp) AS label,usr.usr_email AS email FROM courier cr JOIN company cmp ON cmp.cmp_id=cr.cmp_id JOIN user usr ON usr.usr_id=cr.usr_id WHERE cr.hq_id IN ("+marks(hqs.size())+") AND ("+groupWhere("cr.cr_group",d.groups().size())+")"+consent+" ORDER BY cr.cr_id LIMIT 5001",p.toArray()));
|
||||
addRows(result,db.queryForList("SELECT DISTINCT emp.emp_id AS id,'emp' AS type,CONCAT_WS(' ',usr.usr_firstname,usr.usr_name) AS label,usr.usr_email AS email FROM employee emp JOIN user usr ON usr.usr_id=emp.usr_id WHERE usr.usr_type=1 AND usr.hq_id IN ("+marks(hqs.size())+") AND ("+groupWhere("emp.emp_group",d.groups().size())+") ORDER BY emp.emp_id LIMIT 5001",p.toArray()));}
|
||||
}
|
||||
result=new ArrayList<>(new LinkedHashSet<>(result));if(result.isEmpty()||result.size()>5000)throw bad("Bitte 1 bis 5000 Empfänger auswählen. Größere Verteiler nach Niederlassung, Branche oder Gruppe eingrenzen.");
|
||||
if(needHash){String instance=parameter(0,"GLOBAL_UNIQUE_DB_INSTANCE_NO");if(!instance.matches("[1-9][0-9]{0,9}"))throw bad("Die Datenbank-Instanznummer für persönliche Abmeldelinks ist nicht konfiguriert.");var hashes=new HashMap<String,String>();for(int start=0;start<result.size();start+=100){var slice=result.subList(start,Math.min(start+100,result.size()));var args=new ArrayList<Object>(List.of(Long.parseLong(instance)));for(var r:slice){args.add(r.type());args.add(r.id());}for(var row:db.queryMetaForList("SELECT mo_obj_type,mo_obj_id,mo_hash FROM metaobject WHERE mo_id_ref_db=? AND ("+String.join(" OR ",Collections.nCopies(slice.size(),"(mo_obj_type=? AND mo_obj_id=?)"))+")",args.toArray())){String key=text(row,"mo_obj_type")+":"+id(row,"mo_obj_id"),hash=text(row,"mo_hash");if(!hash.matches("[A-Za-z0-9_-]{1,200}")||hashes.putIfAbsent(key,hash)!=null&&!hashes.get(key).equals(hash))throw bad("Ungültiger oder mehrdeutiger Newsletter-Abmeldeschlüssel.");}}
|
||||
var personalized=new ArrayList<Recipient>();for(var r:result){String hash=hashes.get(r.type()+":"+r.id());if(hash==null)throw bad("Für "+r.label()+" fehlt der persönliche Abmeldeschlüssel.");personalized.add(new Recipient(r.address(),r.type(),r.id(),r.label(),hash));}result=personalized;}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
static Object[] concat(List<?> a,List<?> b){var all=new ArrayList<Object>(a);all.addAll(b);return all.toArray();}
|
||||
static String groupWhere(String column,int count){return String.join(" OR ",Collections.nCopies(count,column+" LIKE ?"));}
|
||||
static void addRows(List<Recipient> out,List<Map<String,Object>> rows){if(rows.size()>5000)throw bad("Mehr als 5000 Empfänger. Bitte Auswahl eingrenzen.");for(var row:rows){String raw=text(row,"email");if(raw.isBlank())continue;for(String address:addresses(raw))out.add(new Recipient(address,text(row,"type"),id(row,"id"),text(row,"label"),""));}}
|
||||
@SuppressWarnings("unchecked") Map<String,Object> preview(LegacyUser u,long object,Draft draft){newsletters.access(u);if(draft==null)throw bad("Versanddaten fehlen.");String subject=required(draft.subject(),200,"Betreff");if(subject.contains("\r")||subject.contains("\n"))throw bad("Ungültiger Betreff.");if(!transport.configured())throw bad("E-Mail-Versand ist nicht konfiguriert.");var rendering=new LinkedHashMap<>(newsletters.rendering(u,draft.headquarters(),object));String template=text(rendering,"template");boolean personalized=template.contains("<postparser>HASH</postparser>");rendering.put("template",template.replace("<postparser>HASH</postparser>",HASH));if(text(rendering,"template").contains("<postparser>"))throw bad("Die Vorlage enthält einen unbekannten Mail-Platzhalter.");String html=templates.render(rendering);var recipients=recipients(u,draft,personalized);var p=new Pending();p.actor=u;p.object=object;p.draft=draft;p.revision=text(rendering,"revision");p.recipientRevision=revision(recipients);p.recipients=recipients;p.html=html;p.cc=addresses(draft.cc());p.bcc=addresses(draft.bcc());if((!p.cc.isEmpty()||!p.bcc.isEmpty())&&!draft.copiesEveryMail())throw bad("Für Cc/Bcc bitte die Kopie bei jeder generierten Mail bestätigen.");p.reply=AppointmentNotifications.address(text(one(db.queryForList("SELECT usr_email FROM user WHERE usr_id=? AND hq_id=?",u.userId(),u.headquartersId())),"usr_email"));String sender=parameter(u.headquartersId(),"MAIL_NEWSLETTER_SENDER_ADDRESS");if(sender.isBlank())sender=parameter(u.headquartersId(),"MAIL_SENDER_ADDRESS");p.from=AppointmentNotifications.address(!transport.from.isBlank()?transport.from:sender.isBlank()?p.reply:sender);p.expires=Instant.now().plusSeconds(900);String token=UUID.randomUUID().toString();synchronized(pending){pending.values().removeIf(v->!v.running&&v.expires.isBefore(Instant.now()));if(pending.size()>=20)throw bad("Zu viele Newsletter-Vorschauen. Bitte später erneut versuchen.");pending.put(token,p);}return fields("token",token,"from",p.from,"replyTo",p.reply,"subject",subject,"recipients",recipients.stream().map(r->fields("address",r.address(),"label",r.label())).toList(),"cc",p.cc,"bcc",p.bcc,"personalized",personalized,"html",previewHtml(html));}
|
||||
static String previewHtml(String html){var doc=org.jsoup.Jsoup.parse(html);doc.select("a").removeAttr("href");return doc.html();}
|
||||
Pending checked(LegacyUser u,String token){newsletters.access(u);Pending p;synchronized(pending){p=pending.get(token);}if(p==null||p.actor.userId()!=u.userId()||p.actor.headquartersId()!=u.headquartersId()||!p.running&&p.expires.isBefore(Instant.now()))throw bad("Versandvorschau ist abgelaufen oder durch einen Serverneustart nicht mehr verfügbar. Einen zuvor gestarteten Versand zuerst anhand der SMTP-Protokolle prüfen; er wird nicht automatisch wiederholt.");return p;}
|
||||
Map<String,Object> send(LegacyUser u,String token){var p=checked(u,token);synchronized(p){if(!p.started){var current=newsletters.rendering(u,p.draft.headquarters(),p.object);if(!Objects.equals(current.get("revision"),p.revision))throw bad("Newsletter oder Vorlage wurde inzwischen geändert. Bitte Vorschau neu erstellen.");unchanged(recipients(u,p.draft,p.html.contains(HASH)),p.recipientRevision);p.started=true;p.running=true;p.expires=Instant.now().plusSeconds(86400);executor.execute(()->deliver(p));}return status(p);}}
|
||||
void deliver(Pending p){try{for(var r:p.recipients){if(Thread.currentThread().isInterrupted()){synchronized(p){p.warning="Versand durch Serverneustart unterbrochen. Vor einer Wiederholung Zustellung prüfen.";}break;}try{transport.sendHtml(p.from,p.reply,r.address(),p.cc,p.bcc,p.draft.subject().strip(),p.html.replace(HASH,r.hash()));synchronized(p){p.sent++;}}catch(RuntimeException e){synchronized(p){p.uncertain=1;p.warning="Die Übergabe an "+r.address()+" konnte nicht bestätigt werden. Die übrigen Empfänger wurden nicht versucht. Zustellung vor einer neuen Versandvorschau prüfen.";}break;}}}finally{synchronized(p){p.running=false;}}}
|
||||
Map<String,Object> status(LegacyUser u,String token){var p=checked(u,token);synchronized(p){return status(p);}}
|
||||
static Map<String,Object> status(Pending p){return fields("started",p.started,"running",p.running,"sent",p.sent,"uncertain",p.uncertain,"unattempted",p.recipients.size()-p.sent-p.uncertain,"total",p.recipients.size(),"warning",p.warning);}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.nio.file.*;
|
||||
import java.io.*;
|
||||
import java.util.*;
|
||||
import javax.imageio.ImageIO;
|
||||
import static de.stadtbote.votian.AdminSupport.bad;
|
||||
@Component
|
||||
class NewsletterImages {
|
||||
final Path directory;
|
||||
NewsletterImages(@Value("${app.form-images-directory:data/form-images}")String path){directory=Path.of(path).toAbsolutePath().normalize();}
|
||||
String upload(LegacyUser u,String data){u.require(0);u.require(17);if(data==null||data.length()>410000||!data.matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=\\r\\n]+"))throw bad("Bitte ein PNG-, JPG- oder GIF-Bild bis 300 KB auswählen.");byte[] bytes;try{bytes=Base64.getMimeDecoder().decode(data.substring(data.indexOf(',')+1));}catch(IllegalArgumentException e){throw bad("Ungültige Bilddatei.");}if(bytes.length>300000)throw bad("Bilder dürfen höchstens 300 KB groß sein.");
|
||||
try(var input=ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))){var readers=ImageIO.getImageReaders(input);if(!readers.hasNext())throw bad("Die Datei enthält kein unterstütztes Bild.");var reader=readers.next();try{reader.setInput(input);String format=reader.getFormatName().toLowerCase(Locale.ROOT);if(!Set.of("png","jpeg","gif").contains(format)||!data.startsWith("data:image/"+format+";"))throw bad("Bildinhalt und Dateityp stimmen nicht überein.");if(reader.getWidth(0)>8000||reader.getHeight(0)>8000||(long)reader.getWidth(0)*reader.getHeight(0)>32000000)throw bad("Die Bildabmessungen sind zu groß.");reader.read(0);String name="ng_"+UUID.randomUUID()+"."+(format.equals("jpeg")?"jpg":format);Files.createDirectories(directory);Files.write(directory.resolve(name),bytes,StandardOpenOption.CREATE_NEW);return name;}finally{reader.dispose();}}catch(IOException e){throw bad("Bild konnte nicht gespeichert werden. Bitte das konfigurierte Bildverzeichnis prüfen.");}
|
||||
}
|
||||
String data(String name){if(!name.matches("ng_[0-9a-f-]{36}\\.(png|jpg|gif)"))return null;try{Path path=directory.resolve(name);if(!Files.isRegularFile(path,LinkOption.NOFOLLOW_LINKS))return null;if(Files.size(path)>300000)throw bad("Gespeicherte Bilddatei überschreitet 300 KB.");String type=name.endsWith(".jpg")?"jpeg":name.endsWith(".gif")?"gif":"png";return "data:image/"+type+";base64,"+Base64.getEncoder().encodeToString(Files.readAllBytes(path));}catch(IOException e){throw bad("Gespeicherte Bilddatei ist nicht lesbar.");}}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
@Service
|
||||
class NewsletterService extends MetafieldRecords {
|
||||
static final String DEFINITIONS=definitionsSql(10);
|
||||
NewsletterService(DatabaseService db,FormAdministration forms,MessageAdministration messages){super(db,forms,messages,10);}
|
||||
void access(LegacyUser u){u.require(0);u.require(17);}
|
||||
String validateValue(LegacyUser u,long hq,Map<String,Object> def,String value){String type=text(def,"type");if(!Set.of("text","varchar","image","link","url_business","checkbox").contains(type))throw bad(text(def,"label")+": Dieser Feldtyp ist kein Newsletter-Eingabefeld.");if(type.equals("image"))NewsletterTemplate.imageParts(value);if(type.equals("checkbox")&&!Set.of("","1").contains(value))throw bad("Ungültige Checkbox.");return value;}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import static de.stadtbote.votian.AdminSupport.bad;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Renders newsletter placeholders as data. PHP/JavaScript from templates is never executed. */
|
||||
@Component
|
||||
class NewsletterTemplate {
|
||||
private final String legacy;private final NewsletterImages images;
|
||||
NewsletterTemplate(@Value("${app.legacy-url:}")String legacy,NewsletterImages images){this.legacy=legacy.replaceAll("/+$","");this.images=images;}
|
||||
static String[] imageParts(String raw){if(raw==null||raw.isBlank())return new String[]{"","","",""};String[] p=raw.split(",",4);String name=p[0].strip();if(!name.matches("[\\p{L}\\p{N}_ .()-]{1,200}\\.(?i:png|jpe?g|gif|webp)")||name.contains(".."))throw bad("Bildname muss eine PNG-, JPG-, GIF- oder WebP-Datei ohne Pfadangabe sein.");for(int i=1;i<Math.min(3,p.length);i++)if(!p[i].isBlank()&&(!p[i].strip().matches("[1-9][0-9]{0,3}")||Integer.parseInt(p[i].strip())>4000))throw bad("Bildabmessungen müssen zwischen 1 und 4000 liegen.");return new String[]{name,p.length>1?p[1].strip():"",p.length>2?p[2].strip():"",p.length>3?p[3]:""};}
|
||||
static String escape(String s){return HtmlUtils.htmlEscape(s==null?"":s);}
|
||||
String image(String value){if(value.isBlank())return "";String[] p=imageParts(value);String local=images.data(p[0]);String src=local!=null?local:legacy+"/documents/images/"+org.springframework.web.util.UriUtils.encodePathSegment(p[0],java.nio.charset.StandardCharsets.UTF_8);return "<img src=\""+escape(src)+"\" alt=\""+escape(p[3])+"\""+(p[1].isBlank()?"":" width=\""+p[1]+"\"")+(p[2].isBlank()?"":" height=\""+p[2]+"\"")+">";}
|
||||
@SuppressWarnings("unchecked") String render(Map<String,Object> rendering){var defs=(List<Map<String,Object>>)rendering.get("definitions");var data=(Map<String,Object>)rendering.get("data");var vals=(Map<Long,String>)data.get("values");String template=(String)rendering.get("template");var byKey=new HashMap<Long,String>();for(var d:defs){String type=text(d,"type"),v=vals.getOrDefault(id(d,"id"),"");String output=switch(type){case "image"->image(v);case "checkbox"->v.equals("1")?"✓":"";case "text"->Jsoup.clean(v.replace("\n","<br>"),Safelist.basic());default->escape(v);};byKey.put(id(d,"keyId"),output);}if(template.isBlank()){var out=new StringBuilder();for(var d:defs)out.append("<h3>").append(escape(text(d,"label"))).append("</h3><div>").append(byKey.get(id(d,"keyId"))).append("</div>");return clean(out.toString());}
|
||||
var matcher=Pattern.compile("\\[-([0-9]{1,10})(?:,[^\\]]*)?-]").matcher(template);var out=new StringBuffer();while(matcher.find())matcher.appendReplacement(out,Matcher.quoteReplacement(byKey.getOrDefault(Long.parseLong(matcher.group(1)),"")));matcher.appendTail(out);String html=out.toString();if(html.contains("[-"))throw bad("Die Newslettervorlage enthält nicht unterstützte Spezialplatzhalter.");
|
||||
// PHP removes an entire numbered container when its CHK_EMPTY element is empty.
|
||||
for(int pass=0;pass<1000;pass++){String before=html;var check=Pattern.compile("(?i)<CHK_EMPTY_([0-9]+)>").matcher(html);var empty=new HashSet<String>();String upper=html.toUpperCase(Locale.ROOT);while(check.find()){int end=upper.indexOf("</CHK_EMPTY_"+check.group(1)+">",check.end());if(end>=0){String content=html.substring(check.end(),end);if(Jsoup.parse(content).text().isBlank()&&!content.toLowerCase(Locale.ROOT).contains("<img"))empty.add(check.group(1));}}for(String n:empty)html=html.replaceAll("(?is)<REMOVE_EMPTY_"+n+">.*?</REMOVE_EMPTY_"+n+">","");if(html.equals(before))break;}
|
||||
html=html.replaceAll("(?is)</?CHK_EMPTY_[0-9]+>","");
|
||||
html=html.replaceAll("(?is)</?REMOVE_EMPTY_[0-9]+>","");return clean(html);
|
||||
}
|
||||
String clean(String html){var doc=Jsoup.parse(html,legacy+"/admin/");doc.select("script,iframe,object,embed,form,input,button,textarea,select,meta,link,base").remove();
|
||||
// Only display properties used by the PHP newsletter layouts survive; no URLs/imports in CSS.
|
||||
for(var el:doc.select("[style]"))el.attr("style",safeStyle(el.attr("style")));
|
||||
StringBuilder css=new StringBuilder();for(var el:doc.select("style")){var m=Pattern.compile("([a-zA-Z0-9_.# \\s:>,\\-]+)\\{([^{}]*)}").matcher(el.data());while(m.find())css.append(m.group(1)).append('{').append(safeStyle(m.group(2))).append('}');}doc.select("style").remove();
|
||||
for(var image:doc.select("img[src]")){String abs=image.absUrl("src");if(abs.startsWith(legacy+"/")&&!legacy.isBlank())image.attr("src",abs);else if(!image.attr("src").matches("data:image/(png|jpeg|gif);base64,[A-Za-z0-9+/=]+"))image.remove();}
|
||||
var safe=Safelist.relaxed().addTags("font","hr").addAttributes(":all","style","class").addAttributes("table","width","cellpadding","cellspacing","border").addAttributes("td","width","height","align","valign","bgcolor").addAttributes("font","color","face","size").addProtocols("img","src","https","http","data");
|
||||
return "<style>body{font-family:Arial,sans-serif;margin:20px}img{max-width:100%;height:auto}"+css+"</style>"+Jsoup.clean(doc.body().html(),legacy+"/admin/",safe,new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false));
|
||||
}
|
||||
static String safeStyle(String css){var allowed=Set.of("color","background-color","font-family","font-size","font-weight","font-style","text-align","text-decoration","line-height","vertical-align","width","height","max-width","border","border-collapse","border-color","border-width","border-style","padding","padding-left","padding-right","padding-top","padding-bottom","margin","margin-left","margin-right","margin-top","margin-bottom","display","float","clear","min-width","min-height","box-sizing","white-space","page-break-before","page-break-after","page-break-inside","break-inside");var out=new StringBuilder();for(String part:css.split(";")){String[] kv=part.split(":",2);if(kv.length==2&&allowed.contains(kv[0].strip().toLowerCase(Locale.ROOT))&&kv[1].matches("[a-zA-Z0-9#.,%() '"+'"'+"-]+")&&!kv[1].toLowerCase(Locale.ROOT).matches(".*(url|expression|javascript|behavior).*"))out.append(kv[0].strip()).append(':').append(kv[1].strip()).append(';');}return out.toString();}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.Validator;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.number;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Article snapshots are edited in place. Stock corrections append journal entries. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{id}/articles")
|
||||
public class OrderArticles {
|
||||
final DatabaseService db;final BusinessSupport support;final OrderOperations orders;
|
||||
final OrderEntryInventory inventory;final Validator validator;
|
||||
public OrderArticles(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderEntryInventory inventory,Validator validator){this.db=db;this.support=support;this.orders=orders;this.inventory=inventory;this.validator=validator;}
|
||||
public record Position(@Min(0) long id,@Positive long stationId,@Min(0) long articleId,
|
||||
@NotBlank @Size(max=30) String name,@Min(1) @Max(32767) int quantity,
|
||||
@NotNull @DecimalMin("0") @DecimalMax("999999.999") @Digits(integer=6,fraction=3) BigDecimal weight,
|
||||
@Min(0) @Max(127) int packingPieces,@NotNull @DecimalMin("0") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal price,
|
||||
@NotNull @Size(max=50) String serialNumber,@NotNull @Size(max=255) String remark) {}
|
||||
public record Save(String requestId,String revision,@NotNull @Size(max=100) List<@NotNull Position> positions) {}
|
||||
public record Movement(String requestId,String revision,@Min(0) long reverseId,@Valid ArticleItem item,@NotBlank @Size(max=180) String reason,String fingerprint,@Valid Extra extra) {
|
||||
public Movement(String requestId,String revision,long reverseId,ArticleItem item,String reason,String fingerprint){this(requestId,revision,reverseId,item,reason,fingerprint,null);}
|
||||
}
|
||||
public record Extra(@Size(max=20) String transaction,java.time.LocalDate specialDate,@Size(max=25) List<@NotNull @Size(max=50) String> data){}
|
||||
record Planned(long reverseId,long bundleId,ArticleItem item,String transaction,LocalDateTime specialDate,List<String> data){}
|
||||
record State(OrderOperations.Snapshot order,List<Map<String,Object>> articles,List<Map<String,Object>> scans,List<Map<String,Object>> moves,List<Map<String,Object>> reversals,List<Map<String,Object>> itemData){}
|
||||
@GetMapping public Object get(Authentication auth,@PathVariable long id){return view(ApiController.user(auth),id);}
|
||||
@PutMapping public Object put(Authentication auth,@PathVariable long id,@Valid @RequestBody Save input){return save(ApiController.user(auth),id,input);}
|
||||
@PostMapping("/movement/preview") public Object preview(Authentication auth,@PathVariable long id,@Valid @RequestBody Movement input){return preview(ApiController.user(auth),id,input);}
|
||||
@PostMapping("/movement") public Object move(Authentication auth,@PathVariable long id,@Valid @RequestBody Movement input){return move(ApiController.user(auth),id,input);}
|
||||
State state(LegacyUser u,long id,boolean lock){
|
||||
u.require(4);var order=orders.snapshot(u,id,lock);
|
||||
var articles=read(lock,"SELECT * FROM tourarticle WHERE jb_id=? ORDER BY trat_sort,trat_id",id);
|
||||
var scans=read(lock,"SELECT p.* FROM tourarticleprocess p JOIN tourarticle a ON a.trat_id=p.trat_id WHERE a.jb_id=? ORDER BY p.trat_id,p.tratp_id",id);
|
||||
var moves=u.hasRight(14)?read(lock,"SELECT * FROM stockmove WHERE jb_id=? ORDER BY stkmv_id",id):List.<Map<String,Object>>of();
|
||||
var reversals=u.hasRight(14)?read(lock,"SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='ngstkrv' AND gdc_obj_id=? ORDER BY gdc_id",id):List.<Map<String,Object>>of();
|
||||
var itemData=u.hasRight(14)?read(lock,"SELECT g.gdc_obj_id,g.gdc_content FROM genericdatacontainer g JOIN stockmove m ON m.stkmv_id=g.gdc_obj_id WHERE g.gdc_obj_type='ngstkmeta' AND g.gdc_gen_fieldname='articleitem' AND m.jb_id=? ORDER BY g.gdc_id",id):List.<Map<String,Object>>of();
|
||||
return new State(order,articles,scans,moves,reversals,itemData);
|
||||
}
|
||||
List<Map<String,Object>> read(boolean lock,String sql,Object...args){return lock?db.lockRows(sql,args):db.queryForList(sql,args);}
|
||||
Position position(Map<String,Object> r){return new Position(id(r,"trat_id"),id(r,"tr_id"),id(r,"at_id"),text(r,"trat_name"),(int)id(r,"trat_quantity"),number(r,"trat_weight"),(int)id(r,"trat_packingpieces"),number(r,"trat_price"),text(r,"trat_serialno"),text(r,"trat_remark"));}
|
||||
boolean protectedPosition(State s,Map<String,Object> r){return id(r,"trat_state")!=0||s.scans().stream().anyMatch(p->id(p,"trat_id")==id(r,"trat_id"))||s.order().stops().stream().anyMatch(t->id(t,"tr_id")==id(r,"tr_id")&&id(t,"tr_status")==1);}
|
||||
Map<String,Object> view(LegacyUser u,long id){return db.readTransaction(()->{
|
||||
var s=state(u,id,false);
|
||||
return fields("revision",revision(s),"payerId",OrderRecalculation.payerId(s.order()),"canBook",u.hasRight(7)&&u.hasRight(14),"positions",s.articles().stream().map(this::position).toList(),"protectedIds",s.articles().stream().filter(r->protectedPosition(s,r)).map(r->id(r,"trat_id")).toList(),
|
||||
"stations",s.order().stops().stream().map(t->fields("id",id(t,"tr_id"),"label",id(t,"tr_sort")+". "+text(t,"tr_comp"),"completed",id(t,"tr_status")==1)).toList(),
|
||||
"movements",s.moves().stream().map(m->fields("id",id(m,"stkmv_id"),"at",text(m,"stkmv_timestamp"),"articleId",id(m,"at_id"),"quantity",id(m,"stkmv_itemquantity"),"from",id(m,"stk_id_from"),"to",id(m,"stk_id_to"),"serial",text(m,"stkmv_serialno"),"remark",text(m,"stkmv_remark"),"bundleId",id(m,"at_id_bundle"),"transaction",text(m,"stkmv_tan"),"specialDate",text(m,"stkmv_datetime2"),"data",text(m,"stkmv_data"),"reversed",s.reversals().stream().anyMatch(r->text(r,"gdc_gen_fieldname").equals(Long.toString(id(m,"stkmv_id")))))).toList());
|
||||
});}
|
||||
void validate(Object input){if(input==null||!validator.validate(input).isEmpty())throw bad("Bitte Artikel- und Mengenangaben prüfen.");}
|
||||
Map<String,Object> save(LegacyUser u,long id,Save input){u.require(4);u.require(7);validate(input);token(input.requestId());
|
||||
support.transactional("headquarters","job","tour","tourarticle","tourarticleprocess","article","genericdatacontainer","phoenix_pda.commandexec");
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,input.requestId(),List.of("articles",id,input))!=null)return null;
|
||||
var s=state(u,id,true);unchanged(s,input.revision());orders.writable(s.order());
|
||||
var old=new HashMap<Long,Map<String,Object>>();s.articles().forEach(r->old.put(id(r,"trat_id"),r));var seen=new HashSet<Long>();
|
||||
for(var p:input.positions()){
|
||||
if(p.id()>0&&(!old.containsKey(p.id())||!seen.add(p.id())))throw bad("Unbekannte oder doppelte Artikelposition.");
|
||||
var before=old.get(p.id());if(before!=null&&same(position(before),p))continue;
|
||||
validate(p);
|
||||
if(before!=null&&protectedPosition(s,before))throw bad("Bereits gescannte oder abgeschlossene Artikelpositionen bleiben als Nachweis erhalten.");
|
||||
var stop=s.order().stops().stream().filter(t->id(t,"tr_id")==p.stationId()).findFirst().orElseThrow(()->bad("Die Station gehört nicht zu diesem Auftrag."));
|
||||
if(id(stop,"tr_status")==1)throw bad("An abgeschlossenen Stationen können keine Artikelpositionen geändert werden.");
|
||||
if(p.articleId()>0){var article=inventory.article(u,p.articleId());if(id(article,"at_serialno")!=0&&p.serialNumber().isBlank())throw bad("Der Artikel benötigt eine Seriennummer.");}
|
||||
if(!p.serialNumber().isBlank()&&p.quantity()!=1)throw bad("Seriennummern erfordern Stückzahl 1.");
|
||||
}
|
||||
for(var r:s.articles())if(!seen.contains(id(r,"trat_id"))){if(protectedPosition(s,r))throw bad("Bereits gescannte oder abgeschlossene Artikelpositionen können nicht entfernt werden.");db.update("DELETE FROM tourarticle WHERE trat_id=? AND jb_id=?",id(r,"trat_id"),id);}
|
||||
int sort=s.articles().stream().mapToInt(r->(int)id(r,"trat_sort")).max().orElse(0);
|
||||
for(var p:input.positions()){
|
||||
var before=old.get(p.id());if(before!=null&&same(position(before),p))continue;
|
||||
long sequence=s.order().stops().stream().filter(t->id(t,"tr_id")==p.stationId()).mapToLong(t->id(t,"tr_sort")).findFirst().orElseThrow();
|
||||
var row=fields("tr_id",p.stationId(),"tr_sort",sequence,"at_id",p.articleId(),"trat_name",p.name(),"trat_quantity",p.quantity(),"trat_weight",p.weight(),"trat_packingpieces",p.packingPieces(),"trat_price",p.price(),"trat_serialno",p.serialNumber(),"trat_remark",p.remark());
|
||||
if(p.id()==0){row.putAll(fields("jb_id",id,"trat_sort",++sort,"trat_state",0,"trat_int_serialno","","trat_createtime",LocalDateTime.now()));db.insert("tourarticle",row);}else update(db,"tourarticle","trat_id",p.id(),row);
|
||||
}
|
||||
changed(u,id,s.order(),input.requestId(),"Artikelpositionen bearbeitet; Lagerjournal unverändert");support.receipt(u,input.requestId(),List.of("articles",id,input),id);return null;
|
||||
});return view(u,id);
|
||||
}
|
||||
static boolean same(Position a,Position b){return a.id()==b.id()&&a.stationId()==b.stationId()&&a.articleId()==b.articleId()&&a.quantity()==b.quantity()&&a.packingPieces()==b.packingPieces()&&Objects.equals(a.name(),b.name())&&Objects.equals(a.serialNumber(),b.serialNumber())&&Objects.equals(a.remark(),b.remark())&&b.weight()!=null&&b.price()!=null&&a.weight().compareTo(b.weight())==0&&a.price().compareTo(b.price())==0;}
|
||||
List<Planned> plan(State s,Movement m){
|
||||
if(m.reverseId()==0){
|
||||
if(m.item()==null||m.item().articleId()<=0||m.item().booking()==null||m.item().freeArticle()!=null)throw bad("Artikel und Lagerorte für die Bewegung fehlen.");
|
||||
var item=new ArticleItem(m.item().articleId(),1,m.item().quantity(),m.item().serialNumber(),m.reason(),m.item().booking());
|
||||
var extra=m.extra();return List.of(new Planned(0,0,item,extra==null||extra.transaction()==null?Long.toString(id(s.order().job(),"jb_id")):extra.transaction(),extra==null||extra.specialDate()==null?null:extra.specialDate().atStartOfDay(),extra==null||extra.data()==null?List.of():extra.data()));
|
||||
}
|
||||
var old=s.moves().stream().filter(r->id(r,"stkmv_id")==m.reverseId()).findFirst().orElseThrow(()->bad("Lagerbewegung gehört nicht zum Auftrag."));
|
||||
var group=new ArrayList<Map<String,Object>>();long bundle=id(old,"at_id_bundle");
|
||||
if(bundle>0){
|
||||
var at=dateTime(old,"stkmv_datetime");if(at==null)throw bad("Der Bündelbewegung fehlt ein eindeutiger Buchungszeitpunkt.");
|
||||
var related=db.queryForList("SELECT stkmv_id,jb_id FROM stockmove WHERE at_id_bundle=? AND stkmv_datetime=? ORDER BY stkmv_id",bundle,at);
|
||||
if(related.size()>100||related.stream().anyMatch(r->id(r,"jb_id")!=id(s.order().job(),"jb_id")))throw bad("Das Bündel umfasst weitere Aufträge. Bitte gemeinsam in der Lagerverwaltung korrigieren.");
|
||||
for(var r:related)group.add(s.moves().stream().filter(row->id(row,"stkmv_id")==id(r,"stkmv_id")).findFirst().orElseThrow(()->bad("Die Bündelbewegung wurde geändert. Bitte neu laden.")));
|
||||
}else group.add(old);
|
||||
group.sort(Comparator.comparingLong((Map<String,Object> r)->id(r,"stkmv_id")).reversed());
|
||||
var result=new ArrayList<Planned>();
|
||||
for(var row:group){
|
||||
long movement=id(row,"stkmv_id");if(s.reversals().stream().anyMatch(r->text(r,"gdc_gen_fieldname").equals(Long.toString(movement))))throw bad("Für diese Lagerbewegung besteht bereits eine Gegenbuchung.");
|
||||
if(id(row,"stkmv_itemquantity")<1||id(row,"stkmv_itemquantity")>32767)throw bad("Ungültige Menge in der Lagerbewegung.");
|
||||
var data=new ArrayList<String>();var stored=s.itemData().stream().filter(r->id(r,"gdc_obj_id")==movement).toList();
|
||||
if(stored.size()>1)throw bad("Gesicherte Artikeldaten sind nicht eindeutig.");
|
||||
if(stored.size()==1)try{var tree=support.json.readTree(text(stored.getFirst(),"gdc_content"));for(int i=1;i<=25;i++)data.add(tree.path(String.format("ati_data_%02d",i)).asText(""));}catch(java.io.IOException e){throw bad("Gesicherte Artikeldaten sind nicht lesbar.");}
|
||||
else if(!text(row,"stkmv_data").isBlank())data.addAll(Arrays.asList(text(row,"stkmv_data").split("(?i)<br\\s*/?>",-1)));
|
||||
else if(group.size()==1&&m.extra()!=null&&m.extra().data()!=null)data.addAll(m.extra().data());
|
||||
if(id(row,"stk_id_to")==0&&!text(row,"stkmv_serialno").isBlank()&&data.isEmpty())throw bad("Für diese alte Seriennummern-Auslagerung fehlen Zusatzdaten. Bitte die vollständigen Artikeldaten in den Zusatzfeldern ergänzen.");
|
||||
var extra=new Extra(text(row,"stkmv_tan"),null,data);validate(extra);
|
||||
var item=new ArticleItem(id(row,"at_id"),1,(int)id(row,"stkmv_itemquantity"),text(row,"stkmv_serialno"),"Gegenbuchung #"+movement+": "+m.reason(),new StockBooking(id(row,"stk_id_to"),id(row,"stk_id_from")));
|
||||
result.add(new Planned(movement,bundle,item,text(row,"stkmv_tan"),dateTime(row,"stkmv_datetime2"),data));
|
||||
}return result;
|
||||
}
|
||||
Request request(State s,List<Planned> plan){var j=s.order().job();if(id(j,"jb_offer")!=0||id(j,"jb_permanent")!=0)throw bad("Lagerbuchungen benötigen einen Einzelauftrag ohne Angebotsstatus.");if(s.order().stops().isEmpty())throw bad("Der Auftrag besitzt keine Stationen.");for(var p:plan)validate(p.item());return support.json.convertValue(fields("payerCostCenterId",OrderRecalculation.payerId(s.order()),"mode","IMMEDIATE","copies",1,"stops",List.of(Map.of("name","Lagerbuchung","country","DE")),"articles",plan.stream().map(Planned::item).toList()),Request.class);}
|
||||
Map<String,Object> preview(LegacyUser u,long id,Movement m){u.require(4);u.require(7);u.require(14);validate(m);return db.readTransaction(()->{var s=state(u,id,false);unchanged(s,m.revision());orders.writable(s.order());var plan=plan(s,m);var lines=inventory.quote(u,request(s,plan));return fields("item",plan.getFirst().item(),"plan",plan,"lines",lines,"fingerprint",revision(List.of(s,plan,lines)));});}
|
||||
Map<String,Object> move(LegacyUser u,long id,Movement m){u.require(4);u.require(7);u.require(14);validate(m);token(m.requestId());
|
||||
support.transactional("headquarters","job","tour","tourarticle","tourarticleprocess","article","stock","stockarticle","articleitem","stockmove","genericdatacontainer","phoenix_pda.commandexec");
|
||||
db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,m.requestId(),List.of("stock",id,m))!=null)return null;var s=state(u,id,true);unchanged(s,m.revision());orders.writable(s.order());var plan=plan(s,m);var request=request(s,plan);inventory.lock(u,request);var lines=inventory.quote(u,request,true);if(!revision(List.of(s,plan,lines)).equals(m.fingerprint()))throw bad("Die Buchungsvorschau hat sich geändert. Bitte neu prüfen.");
|
||||
var now=LocalDateTime.now().withNano(0);long bundle=plan.getFirst().bundleId();
|
||||
// Legacy journal groups use bundle + second; never merge a correction into an older group.
|
||||
if(bundle>0)while(!db.lockRows("SELECT stkmv_id FROM stockmove WHERE at_id_bundle=? AND stkmv_datetime=?",bundle,now).isEmpty())now=now.plusSeconds(1);
|
||||
for(var p:plan){
|
||||
var item=p.item();long movement=inventory.book(u,item,id,now);
|
||||
update(db,"stockmove","stkmv_id",movement,fields("at_id_bundle",p.bundleId(),"stkmv_tan",p.transaction(),"stkmv_datetime2",p.specialDate()==null?now:p.specialDate(),"stkmv_data",String.join("<br>",p.data())));
|
||||
if(item.booking().fromStockId()==0&&!Objects.toString(item.serialNumber(),"").isBlank()&&!p.data().isEmpty()){
|
||||
var row=new LinkedHashMap<String,Object>();for(int i=1;i<=25;i++)row.put(String.format("ati_data_%02d",i),i<=p.data().size()?p.data().get(i-1):"");
|
||||
long itemId=id(db.lockRows("SELECT ati_id FROM articleitem WHERE at_id=? AND stk_id=? AND ati_serialno=?",item.articleId(),item.booking().toStockId(),item.serialNumber().trim()).getFirst(),"ati_id");update(db,"articleitem","ati_id",itemId,row);
|
||||
}
|
||||
if(p.reverseId()>0)db.insert("genericdatacontainer",fields("gdc_obj_type","ngstkrv","gdc_obj_id",id,"gdc_gen_fieldname",Long.toString(p.reverseId()),"gdc_content",Long.toString(movement),"gdc_context",m.reason()));
|
||||
support.audit(u,id,0,id(s.order().job(),"cr_id"),2,"Lagerbewegung #"+movement+" · "+item.remark(),m.requestId());
|
||||
}
|
||||
changed(u,id,s.order(),m.requestId(),"Lagerkorrektur · "+plan.size()+" Bewegung(en)");support.receipt(u,m.requestId(),List.of("stock",id,m),id);return null;
|
||||
});return view(u,id);
|
||||
}
|
||||
void changed(LegacyUser u,long id,OrderOperations.Snapshot s,String token,String message){db.update("UPDATE job SET jb_version=jb_version+1 WHERE jb_id=?",id);db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_invtext_dirty'",id);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname","jb_invtext_dirty","gdc_content","1","gdc_context",""));if(id(s.job(),"jb_status")==1)orders.command(u,id(s.job(),"cr_id"),"5",Long.toString(id));support.audit(u,id,0,id(s.job(),"cr_id"),2,message,token);}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.Validator;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** A reviewed, atomic correction of a main order and its settlement documents. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{id}/cancellation")
|
||||
public class OrderCancellation {
|
||||
final DatabaseService db;final BusinessSupport support;final OrderOperations orders;final Validator validator;
|
||||
public OrderCancellation(DatabaseService db,BusinessSupport support,OrderOperations orders,Validator validator){this.db=db;this.support=support;this.orders=orders;this.validator=validator;}
|
||||
public record Input(@NotBlank @Pattern(regexp="CANCEL|CREDIT") String action,@NotBlank @Size(max=500) String reason,boolean chargeable,boolean endSeries,boolean offsetPayments){}
|
||||
public record Command(String requestId,String revision,@NotNull @Valid Input input,String fingerprint){}
|
||||
record State(List<OrderOperations.Snapshot> documents,List<Map<String,Object>> metadata){}
|
||||
@GetMapping public Object get(Authentication auth,@PathVariable long id){return options(ApiController.user(auth),id);}
|
||||
@PostMapping("/preview") public Object preview(Authentication auth,@PathVariable long id,@Valid @RequestBody Command c){return preview(ApiController.user(auth),id,c);}
|
||||
@PostMapping public Object save(Authentication auth,@PathVariable long id,@Valid @RequestBody Command c){return save(ApiController.user(auth),id,c);}
|
||||
State state(LegacyUser u,long id,boolean lock){
|
||||
u.require(4);u.require(7);var main=orders.snapshot(u,id,lock);var list=new ArrayList<OrderOperations.Snapshot>();list.add(main);
|
||||
var pairs=new OrderChildRecalculation(db,orders,support).load(u,main,lock);if(!pairs.isEmpty())u.require(38);
|
||||
for(var pair:pairs){list.add(pair.positive());list.add(pair.negative());}
|
||||
var metadata=new ArrayList<Map<String,Object>>();
|
||||
for(var s:list){String sql="SELECT * FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_toll','ng_invoice_lock') ORDER BY gdc_id";metadata.addAll(lock?db.lockRows(sql,id(s.job(),"jb_id")):db.queryForList(sql,id(s.job(),"jb_id")));}
|
||||
return new State(list,metadata);
|
||||
}
|
||||
Map<String,Object> options(LegacyUser u,long id){return db.readTransaction(()->{var s=state(u,id,false);var main=s.documents().getFirst();return fields("revision",revision(s),"canCancel",s.documents().stream().allMatch(d->!OrderOperations.exported(d.job())&&d.invoices().isEmpty()),"canCredit",s.documents().stream().allMatch(d->id(d.job(),"jb_status")==2),"canOffsetPayments",u.hasRight(5),"hasPayments",s.documents().stream().anyMatch(d->!d.payments().isEmpty()),"series",id(main.job(),"jb_permanent")>0,"documents",rows(s,BigDecimal.ZERO,BigDecimal.ZERO));});}
|
||||
State prepare(LegacyUser u,long id,Command c,boolean lock){
|
||||
if(c==null||!validator.validate(c).isEmpty())throw bad("Bitte Stornoart und Begründung prüfen.");var s=state(u,id,lock);unchanged(s,c.revision());
|
||||
if(s.metadata().stream().anyMatch(m->text(m,"gdc_gen_fieldname").equals("ng_invoice_lock")))throw bad("Für einen verbundenen Beleg läuft noch eine Rechnungszuordnung.");
|
||||
for(var d:s.documents()){
|
||||
long job=id(d.job(),"jb_id");if(id(d.job(),"jb_storno")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",job).isEmpty())throw bad("Für einen Beleg besteht bereits ein Storno / eine Gegenbuchung.");
|
||||
var edit=dateTime(d.job(),"jb_locktime");if(edit!=null&&edit.isAfter(LocalDateTime.now().minusMinutes(5)))throw bad("Ein Beleg wird noch in der alten Anwendung bearbeitet.");
|
||||
if(c.input().action().equals("CANCEL"))orders.writable(d);else if(id(d.job(),"jb_status")!=2)throw bad("Gutschriften setzen erledigte Belege voraus.");
|
||||
if(!d.payments().isEmpty()){u.require(5);if(!c.input().offsetPayments())throw bad("Bitte den Ausgleich der vorhandenen Einnahmenbuchungen ausdrücklich bestätigen.");}
|
||||
}
|
||||
var main=s.documents().getFirst();if(c.input().chargeable()&&id(main.job(),"cr_id")<=0&&id(main.job(),"cr_id_order")<=0)throw bad("Kostenpflichtiges Storno benötigt einen Kurier.");return s;
|
||||
}
|
||||
Map<String,Object> quote(LegacyUser u,State s,Command c){
|
||||
BigDecimal fee=BigDecimal.ZERO,courierFee=BigDecimal.ZERO;if(c.input().chargeable()){var tariff=orders.cancellationTariff(u,s.documents().getFirst());fee=tariff.price();}
|
||||
var rows=rows(s,fee,courierFee);return fields("documents",rows,"fee",fee,"courierFee",courierFee,"fingerprint",revision(List.of(s,c.input(),rows,fee,courierFee)));
|
||||
}
|
||||
List<Map<String,Object>> rows(State s,BigDecimal fee,BigDecimal courierFee){
|
||||
var result=new ArrayList<Map<String,Object>>();int index=0;for(var d:s.documents()){
|
||||
long id=id(d.job(),"jb_id");BigDecimal toll=s.metadata().stream().filter(m->id(m,"gdc_obj_id")==id&&text(m,"gdc_gen_fieldname").equals("jb_toll")).map(m->new BigDecimal(text(m,"gdc_content"))).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
result.add(fields("id",id,"kind",index==0?"Hauptauftrag":id(d.job(),"jb_id_parent")==id(s.documents().getFirst().job(),"jb_id")?"Unterauftrag":"Gegenbeleg","courier",text(d.job(),"cr_sid"),"net",OrderOperations.customerNet(d),"creditNet",OrderOperations.customerNet(d).negate().add(index==0?fee:BigDecimal.ZERO),"creditCourierNet",decimal(d.job(),"jb_cr_price").negate().add(index==0?courierFee:BigDecimal.ZERO),"creditToll",toll.negate(),"payments",d.payments().stream().map(p->fields("id",id(p,"jbp_id"),"amount",decimal(p,"jbp_price"),"offset",decimal(p,"jbp_price").negate(),"exported",id(p,"jbpc_id")>0||!text(p,"jbp_export_time").isBlank(),"counter",id(p,"jbp_counter"),"mode",id(p,"jbp_mode"))).toList()));index++;
|
||||
}return result;
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser u,long id,Command c){return db.readTransaction(()->quote(u,prepare(u,id,c,false),c));}
|
||||
Map<String,Object> save(LegacyUser u,long id,Command c){
|
||||
u.require(4);u.require(7);if(c==null)throw bad("Stornoangaben fehlen.");token(c.requestId());orders.transactional();
|
||||
long credit=db.writeTransaction(()->{lockHeadquarters(db,u);Long receipt=support.receipt(u,c.requestId(),List.of("cancel-group",id,c));if(receipt!=null)return receipt;
|
||||
var s=prepare(u,id,c,true);if(!text(quote(u,s,c),"fingerprint").equals(c.fingerprint()))throw bad("Die Stornovorschau hat sich geändert. Bitte neu prüfen.");long result=0;
|
||||
for(var d:s.documents()){
|
||||
long job=id(d.job(),"jb_id"),courier=id(d.job(),"cr_id"),offered=id(d.job(),"cr_id_order");boolean main=job==id;
|
||||
var action=new OrderOperations.Action(c.requestId(),null,c.input().action(),null,null,null,c.input().reason(),main&&c.input().chargeable(),main&&c.input().endSeries());
|
||||
long created=orders.credit(u,job,d,action,c.input().reason());if(main)result=created;
|
||||
var changes=fields("jb_id_copy",created,"jb_version",id(d.job(),"jb_version")+1);
|
||||
if(s.documents().size()>1){changes.put("jb_id_parent",0);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_previous_parent","gdc_content",Long.toString(id(d.job(),"jb_id_parent")),"gdc_context","CANCELLATION"));}
|
||||
if(c.input().action().equals("CANCEL"))changes.putAll(fields("jb_storno",action.chargeable()?1:2,"usr_id_storno",u.userId(),"jb_status",2,"jb_finishtime",LocalDateTime.now(),"cr_id",courier>0?courier:offered));if(action.endSeries())changes.put("jb_permanent",0);update(db,"job","jb_id",job,changes);
|
||||
// Preserve exported and unexported receipts, with an individually linked accounting offset.
|
||||
for(var payment:d.payments()){
|
||||
var copy=new LinkedHashMap<>(payment);copy.remove("jbp_id");copy.putAll(fields("jbp_price",decimal(payment,"jbp_price").negate(),"jbp_bookingtime",LocalDateTime.now(),"jbpc_id",0,"jbp_export_time",null,"jbp_tan","ST"+id(payment,"jbp_id")));
|
||||
long offset=db.insert("jobpayment",copy);db.insert("genericdatacontainer",fields("gdc_obj_type","ngpayrv","gdc_obj_id",id(payment,"jbp_id"),"gdc_gen_fieldname","offset","gdc_content",Long.toString(offset),"gdc_context",Long.toString(created)));
|
||||
}
|
||||
if(id(d.job(),"jb_status")!=2)for(long previous:new TreeSet<>(List.of(courier,offered))){orders.command(u,previous,"3",Long.toString(job));orders.command(u,previous,"8",Long.toString(job));}
|
||||
db.update("DELETE FROM vehicledisposition WHERE jb_id=?",job);db.update("DELETE FROM autoranking WHERE jb_id=?",job);
|
||||
for(long cr:new TreeSet<>(List.of(courier,offered)))if(cr>0)db.update("UPDATE courier SET cr_occupied=(SELECT COUNT(*) FROM job WHERE (cr_id_order=? OR cr_id=?) AND jb_status IN (0,1) AND COALESCE(jb_storno,0)=0) WHERE cr_id=?",cr,cr,cr);
|
||||
support.audit(u,job,0,courier,10,c.input().action()+" gemeinsam mit Auftrag #"+id+" · "+c.input().reason()+" · Gegenbuchung #"+created,c.requestId());
|
||||
}
|
||||
if(s.documents().size()>1){db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_courier_children'",id);db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname","ng_cancelled_group","gdc_content",support.encode(s.documents().stream().map(d->id(d.job(),"jb_id")).toList()),"gdc_context",c.requestId()));}
|
||||
support.receipt(u,c.requestId(),List.of("cancel-group",id,c),result);return result;
|
||||
});return fields("id",id,"resultId",credit,"order",orders.view(u,id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.LegacyOrderPricing.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Updates existing settlement pairs atomically, preserving their IDs and delivery evidence. */
|
||||
class OrderChildRecalculation {
|
||||
final DatabaseService db;final OrderOperations orders;final BusinessSupport support;
|
||||
OrderChildRecalculation(DatabaseService db,OrderOperations orders,BusinessSupport support){this.db=db;this.orders=orders;this.support=support;}
|
||||
record Pair(OrderOperations.Snapshot positive,OrderOperations.Snapshot negative){}
|
||||
List<Pair> load(LegacyUser user,OrderOperations.Snapshot main,boolean lock){
|
||||
if(id(main.job(),"jb_id_parent")>0)throw bad("Unterauftrag bitte gemeinsam über den Hauptauftrag kalkulieren.");
|
||||
var children=read(lock,"SELECT jb_id,hq_id FROM job WHERE jb_id_parent=? AND COALESCE(jb_storno,0)=0 ORDER BY jb_id",id(main.job(),"jb_id"));
|
||||
if(children.size()>20)throw bad("Die Unternehmerverrechnung enthält mehr als 20 Unteraufträge.");
|
||||
if(children.isEmpty()&&id(main.job(),"jb_id_parent")==-1)throw bad("Der Hauptauftrag verweist auf fehlende Unteraufträge.");
|
||||
var pairs=new ArrayList<Pair>();
|
||||
for(var child:children){if(id(child,"hq_id")!=user.headquartersId())throw bad("Verbundene Aufträge liegen in verschiedenen Niederlassungen.");
|
||||
var positive=orders.snapshot(user,id(child,"jb_id"),lock);
|
||||
var negatives=read(lock,"SELECT jb_id,hq_id FROM job WHERE jb_id_parent=? AND COALESCE(jb_storno,0)=0 ORDER BY jb_id",id(child,"jb_id"));
|
||||
if(negatives.size()!=1||id(negatives.getFirst(),"hq_id")!=user.headquartersId())throw bad("Zum Unterauftrag fehlt ein eindeutiger Gegenbeleg in dieser Niederlassung.");
|
||||
var negative=orders.snapshot(user,id(negatives.getFirst(),"jb_id"),lock);
|
||||
if(!read(lock,"SELECT jb_id FROM job WHERE jb_id_parent=?",id(negative.job(),"jb_id")).isEmpty())throw bad("Mehrstufige Sonderverrechnung kann nicht als einfaches Belegpaar geändert werden.");
|
||||
for(String field:List.of("jb_totalprice","jb_cr_price","jb_fixprice","jb_serviceprice"))
|
||||
if(decimal(positive.job(),field).signum()<0||decimal(positive.job(),field).add(decimal(negative.job(),field)).abs().compareTo(new BigDecimal("0.005"))>0)throw bad("Unterauftrag und Gegenbeleg bilden kein ausgeglichenes Verrechnungspaar.");
|
||||
if(id(negative.job(),"cr_id")!=id(main.job(),"cr_id"))throw bad("Der Gegenbeleg gehört nicht zum ausführenden Unternehmer des Hauptauftrags.");
|
||||
pairs.add(new Pair(positive,negative));
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
List<Map<String,Object>> read(boolean lock,String sql,Object... args){return lock?db.lockRows(sql,args):db.queryForList(sql,args);}
|
||||
List<CourierShare> shares(LegacyUser user,List<Pair> pairs){var result=new ArrayList<CourierShare>();for(var p:pairs){var j=p.positive().job();var vehicles=db.queryForList("SELECT v.crvh_id FROM couriervehicle v JOIN courier c ON c.cr_id=v.cr_id WHERE v.cr_id=? AND v.crvh_sid=? AND c.hq_id=?",id(j,"cr_id"),text(j,"cr_sid"),user.headquartersId());if(vehicles.size()!=1)throw bad("Das Fahrzeug eines Unterauftrags ist nicht eindeutig. Bitte den Fahrzeugstamm prüfen.");result.add(new CourierShare(id(vehicles.getFirst(),"crvh_id"),decimal(j,"jb_fixprice")));}return result;}
|
||||
List<Long> ids(List<Pair> pairs,OrderRecalculation.Input input){
|
||||
if(input.pairIds()!=null){if(input.pairIds().size()!=input.courierShares().size())throw bad("Belegzuordnung und Unternehmerbeträge stimmen nicht überein.");return input.pairIds();}
|
||||
if(pairs.size()!=input.courierShares().size())throw bad("Bitte die Kalkulation neu öffnen, um Belegpaare hinzuzufügen oder zu entfernen.");
|
||||
return pairs.stream().map(p->id(p.positive().job(),"jb_id")).toList();
|
||||
}
|
||||
List<Map<String,Object>> changes(List<Pair> pairs,OrderRecalculation.Input input){
|
||||
var ids=ids(pairs,input);var result=new ArrayList<Map<String,Object>>();
|
||||
for(long selected:ids){var pair=pairs.stream().filter(p->id(p.positive().job(),"jb_id")==selected).findFirst();result.add(fields("action",selected==0?"ADD":"UPDATE","positive",selected,"negative",pair.map(p->id(p.negative().job(),"jb_id")).orElse(0L)));}
|
||||
for(var pair:pairs)if(!ids.contains(id(pair.positive().job(),"jb_id")))result.add(fields("action","REMOVE","positive",id(pair.positive().job(),"jb_id"),"negative",id(pair.negative().job(),"jb_id")));
|
||||
return result;
|
||||
}
|
||||
void validate(LegacyUser user,OrderOperations.Snapshot main,List<Pair> pairs,OrderRecalculation.Input input){
|
||||
var selected=ids(pairs,input);if(pairs.isEmpty()&&selected.isEmpty())return;
|
||||
user.require(38);
|
||||
if(!revision(pairs).equals(input.childrenRevision()))throw bad("Unteraufträge wurden zwischenzeitlich geändert. Bitte die Kalkulation neu öffnen.");
|
||||
var seen=new HashSet<Long>();for(long id:selected)if(id>0&&(!seen.add(id)||pairs.stream().noneMatch(p->id(p.positive().job(),"jb_id")==id)))throw bad("Unbekanntes oder doppelt zugeordnetes Unternehmerbelegpaar.");
|
||||
if(id(main.job(),"jb_status")!=2||id(main.job(),"jb_longhaul")!=0)throw bad("Gemeinsame Nachkalkulation benötigt einen erledigten Inlandsauftrag.");
|
||||
for(var p:pairs)for(var s:List.of(p.positive(),p.negative())){
|
||||
orders.writable(s);if(id(s.job(),"jb_status")!=2||!s.payments().isEmpty())throw bad("Verbundene Belege müssen erledigt sein und dürfen keine Einnahmenbuchung enthalten.");
|
||||
}
|
||||
}
|
||||
void write(LegacyUser user,OrderOperations.Snapshot main,List<Pair> pairs,OrderRecalculation.Input input,Quote quote,String token){
|
||||
var selected=ids(pairs,input);if(pairs.isEmpty()&&selected.isEmpty())return;var allocations=quote.courierAllocations();
|
||||
if(allocations.size()!=selected.size())throw bad("Die Kalkulation enthält nicht alle Unternehmerbelege.");
|
||||
var active=new ArrayList<Long>();long mainId=id(main.job(),"jb_id");
|
||||
for(int index=0;index<selected.size();index++){
|
||||
long selectedId=selected.get(index);var a=allocations.get(index);
|
||||
var pair=pairs.stream().filter(p->id(p.positive().job(),"jb_id")==selectedId).findFirst().orElse(null);
|
||||
if(pair==null){long pos=create(user,main,mainId,a);long neg=create(user,main,pos,a);pair=new Pair(orders.snapshot(user,pos,true),orders.snapshot(user,neg,true));}
|
||||
writeOne(user,main,pair.positive(),a,1,quote,index==0,token);writeOne(user,main,pair.negative(),a,-1,quote,index==0,token);
|
||||
active.add(id(pair.positive().job(),"jb_id"));active.add(id(pair.negative().job(),"jb_id"));
|
||||
}
|
||||
for(var pair:pairs)if(!selected.contains(id(pair.positive().job(),"jb_id")))for(var old:List.of(pair.positive(),pair.negative())){
|
||||
long oldId=id(old.job(),"jb_id");String reason="Unternehmerbelegpaar aus Auftrag #"+mainId+" entfernt";
|
||||
long credit=orders.credit(user,oldId,old,new OrderOperations.Action(token,null,"CANCEL",null,null,null,reason,false,false),reason);
|
||||
update(db,"job","jb_id",oldId,fields("jb_storno",2,"jb_id_parent",0,"usr_id_storno",user.userId(),"jb_id_copy",credit,"jb_version",id(old.job(),"jb_version")+1));
|
||||
metadata(oldId,"ng_previous_parent",Long.toString(id(old.job(),"jb_id_parent")));
|
||||
support.audit(user,oldId,0,id(old.job(),"cr_id"),10,reason+" · Gegenbuchung #"+credit,token);
|
||||
}
|
||||
db.update("UPDATE job SET jb_id_parent=? WHERE jb_id=?",active.isEmpty()?0:-1,mainId);
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_courier_children'",mainId);
|
||||
if(!active.isEmpty())metadata(mainId,"ng_courier_children",support.encode(active));
|
||||
}
|
||||
long create(LegacyUser user,OrderOperations.Snapshot main,long parent,CourierAllocation allocation){
|
||||
var source=main.job();var row=fields("hq_id",user.headquartersId(),"csc_id_payer",allocation.payerId(),"jb_status",2,"jb_id_parent",parent,"emp_id",user.userId(),"jb_postage",0,"jb_payment",0,"jb_version",0,"jb_storno",0,"jb_incomplete",1,"jb_freetext_1","","jb_service","","jb_orderer","","jb_scrm","","jb_createtime",java.time.LocalDateTime.now(),"jb_booktime",java.time.LocalDateTime.now(),"jb_invtext","Unternehmerverrechnung zu Auftrag Nr. "+id(source,"jb_id"));
|
||||
for(String field:List.of("vht_id","jb_ordertime","jb_taketime","jb_finishtime","jb_tourdata","jb_invtext_cr","jb_invmode"))if(source.get(field)!=null)row.put(field,source.get(field));
|
||||
long child=db.insert("job",row);OrderPaymentTerms.copy(db,id(source,"jb_id"),child);
|
||||
for(var tour:db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort",id(source,"jb_id"))){var copy=new LinkedHashMap<>(tour);copy.remove("tr_id");copy.remove("tr_modify");copy.put("jb_id",child);db.insert("tour",copy);}return child;
|
||||
}
|
||||
void writeOne(LegacyUser user,OrderOperations.Snapshot main,OrderOperations.Snapshot child,CourierAllocation a,int sign,Quote q,boolean first,String token){
|
||||
long id=id(child.job(),"jb_id");BigDecimal factor=BigDecimal.valueOf(sign);
|
||||
// Keep the original accounting customer rather than silently changing historical relations.
|
||||
if(id(child.job(),"csc_id_payer")!=a.payerId())throw bad("Der Verrechnungszahler hat sich seit der ursprünglichen Buchung geändert.");
|
||||
long courier=sign>0?a.courierId():id(main.job(),"cr_id");String sid=sign>0?a.sid():text(main.job(),"cr_sid");
|
||||
update(db,"job","jb_id",id,fields("jb_totalprice",a.net().multiply(factor),"jb_cr_price",a.net().multiply(factor),"jb_fixprice",a.base().multiply(factor),"jb_subtotalprice",a.subtotal().multiply(factor),"jb_cr_subprice",a.subtotal().subtract(a.service()).multiply(factor),"jb_serviceprice",a.service().multiply(factor),"jb_markup",q.markup(),"jb_sales_tax_rate",a.taxRate(),"jb_sales_tax_rate_sign",a.taxSign(),"cr_id",courier,"cr_id_order",courier,"cr_sid",sid,"jb_version",id(child.job(),"jb_version")+1));
|
||||
db.update("DELETE FROM tourservice WHERE jb_id=?",id);
|
||||
insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",a.payerId(),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis","trs_srvt_name","","trs_price",markup(a.base(),q.markup()).multiply(factor),"trs_discount",a.discount()));
|
||||
if(a.service().signum()>0)insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",a.payerId(),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Servicepreis","trs_srvt_name","","trs_price",a.service().multiply(factor),"trs_discount",0));
|
||||
db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id);
|
||||
var prices=Map.of(1,q.markup(),2,a.subtotal().subtract(a.service()).multiply(factor),3,a.service().multiply(factor),10,a.subtotal().multiply(factor));
|
||||
for(var price:prices.entrySet())insertWithoutKey(db,"jobprice",fields("jb_id",id,"mt_sort",price.getKey(),"jbprc_price",price.getValue(),"jbprc_remark",""));
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_toll','jb_invtext_dirty','ng_calculation_input','ng_calculation_result')",id);
|
||||
if(first&&q.toll()!=null&&q.toll().signum()!=0)metadata(id,"jb_toll",q.toll().multiply(factor).toPlainString());
|
||||
metadata(id,"jb_invtext_dirty","1");
|
||||
support.audit(user,id,0,courier,2,"Unternehmerverrechnung zu Auftrag #"+id(main.job(),"jb_id")+" neu kalkuliert · Netto "+a.net().multiply(factor),token);
|
||||
}
|
||||
void metadata(long id,String key,String value){db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context",""));}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.LegacyOrderPricing.*;
|
||||
|
||||
/** Legacy payment=1 and caglobal.inc.php/saveJobChildren, within the order transaction. */
|
||||
final class OrderEntryAccounting {
|
||||
private static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules();
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
OrderEntryAccounting(DatabaseService db,OrderEntryCatalog catalog) {this.db=db;this.catalog=catalog;}
|
||||
|
||||
Quote quote(LegacyUser user,Request request,Quote quote,List<ArticleLine> articles) {
|
||||
var allocations=new ArrayList<CostAllocation>();
|
||||
if(request.costSplit()) {
|
||||
if(request.payment()!=Payment.INVOICE || quote.batch())throw invalid("Kostensplit benötigt einen direkt abgerechneten Rechnungsauftrag.");
|
||||
var centers=new TreeMap<Long,Map<String,Object>>();
|
||||
centers.put(request.payerCostCenterId(),catalog.costCenter(user,request.payerCostCenterId(),true));
|
||||
for(var stop:request.stops())if(stop.costCenterId()!=null) {
|
||||
var center=catalog.costCenter(user,stop.costCenterId(),false);
|
||||
if(id(center,"csc_is_extern")==0)centers.put(stop.costCenterId(),center);
|
||||
}
|
||||
if(centers.size()<2)throw invalid("Kostensplit benötigt mindestens zwei unterschiedliche Rechnungskostenstellen an den Stationen oder beim Zahler.");
|
||||
int count=centers.size(),index=0;
|
||||
for(var entry:centers.entrySet()) {
|
||||
final int part=index++;
|
||||
BigDecimal net=quote.components().stream().map(c -> portion(c.net(),count,part)).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
BigDecimal toll=portion(nz(request.toll()),count,part),tax=money(net.add(toll).multiply(quote.taxRate()).movePointLeft(2));
|
||||
allocations.add(new CostAllocation(entry.getKey(),text(entry.getValue(),"cmp_comp")+" · "+text(entry.getValue(),"csc_name"),net,toll,tax,net.add(toll).add(tax)));
|
||||
}
|
||||
}
|
||||
var couriers=new ArrayList<CourierAllocation>();
|
||||
if(!request.courierShares().isEmpty()) {
|
||||
user.require(38);
|
||||
if(request.mode()!=Mode.RETROSPECTIVE || request.courierVehicleId()==null || request.stops().stream().anyMatch(s -> !s.country().equals("DE")))
|
||||
throw invalid("Kurier-Unteraufträge sind wie im Bestand nur bei Nacherfassungen im Inland mit ausführendem Fahrzeug möglich.");
|
||||
if(quote.batch() || (quote.route()!=null && quote.route().longhaul()))throw invalid("Kurier-Unteraufträge sind für diesen Abrechnungsmodus nicht verfügbar.");
|
||||
if(request.pricing().courierPrice()!=null || request.pricing().courierServicePrice()!=null || request.pricing().courierMarkup()!=null)
|
||||
throw invalid("Bei Kurier-Unteraufträgen die einzelne Frachtvergütung und den Kurierzuschlag leerlassen. Es gelten die aufgeteilten Beträge und der Kundenzuschlag.");
|
||||
long payer=catalog.calculatorPayer(user);
|
||||
BigDecimal discount=catalog.enabled(user.headquartersId(),"INV_CHILDREN_DISCOUNT")?quote.discount():BigDecimal.ZERO;
|
||||
BigDecimal service=quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
boolean taxOverride=catalog.enabled(0,"MASK_CHILDREN_TAX_HARDCODED");
|
||||
var seen=new HashSet<Long>();
|
||||
BigDecimal base=BigDecimal.ZERO;
|
||||
for(var share:request.courierShares()) {
|
||||
var vehicle=catalog.vehicle(user,share.vehicleId());
|
||||
if(!seen.add(id(vehicle,"cr_id")))throw invalid("Bitte jeden Unternehmer nur einmal in der Kostenaufteilung angeben.");
|
||||
BigDecimal subtotal=discount(share.amount(),discount);
|
||||
BigDecimal net=markup(subtotal,quote.markup()).add(service);
|
||||
couriers.add(new CourierAllocation(share.vehicleId(),id(vehicle,"cr_id"),text(vehicle,"crvh_sid"),share.amount(),subtotal.add(service),net,service,discount,taxOverride?BigDecimal.ZERO:quote.taxRate(),taxOverride?"4":quote.taxSign(),payer));
|
||||
base=base.add(share.amount());
|
||||
}
|
||||
if(base.compareTo(quote.subtotal().subtract(service))>0)throw invalid("Die Summe der Unternehmerbeträge darf den Kundenpreis vor Zuschlag nicht überschreiten.");
|
||||
}
|
||||
if(allocations.isEmpty() && couriers.isEmpty() && articles.isEmpty())return quote;
|
||||
ObjectNode tree=JSON.valueToTree(quote);
|
||||
tree.set("costAllocations",JSON.valueToTree(allocations));tree.set("courierAllocations",JSON.valueToTree(couriers));tree.set("articleLines",JSON.valueToTree(articles));
|
||||
if(!allocations.isEmpty()) {
|
||||
tree.put("tax",allocations.stream().map(CostAllocation::tax).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
tree.put("gross",allocations.stream().map(CostAllocation::gross).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
}
|
||||
if(!couriers.isEmpty()) {
|
||||
BigDecimal net=couriers.stream().map(CourierAllocation::net).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
tree.put("courierNet",net);tree.put("courierSubtotal",couriers.stream().map(CourierAllocation::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
tree.put("courierBase",couriers.stream().map(c -> c.subtotal().subtract(c.service())).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
tree.put("courierService",couriers.stream().map(CourierAllocation::service).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
tree.put("courierMarkup",quote.markup());tree.put("courierDeduction",BigDecimal.ZERO);tree.put("courierAgio",BigDecimal.ZERO);
|
||||
tree.put("courierLimitExceeded",nz(quote.courierLimit()).signum()>0 && net.compareTo(money(quote.net().multiply(quote.courierLimit()).movePointLeft(2)))>0);
|
||||
}
|
||||
tree.put("fingerprint",hash(tree.toString()+"|"+request.costSplit()+"|"+request.courierShares()+"|"+request.articles()));
|
||||
try{return JSON.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
/** Residual cents go to the first stable IDs; the sum is always exact. */
|
||||
static BigDecimal portion(BigDecimal amount,int count,int index) {
|
||||
long cents=money(amount).movePointRight(2).longValueExact();
|
||||
long absolute=Math.abs(cents),value=absolute/count+(index<absolute%count?1:0);
|
||||
return BigDecimal.valueOf(cents<0?-value:value,2);
|
||||
}
|
||||
void split(long orderId,Quote quote) {
|
||||
if(quote.costAllocations()==null || quote.costAllocations().isEmpty())return;
|
||||
db.update("DELETE FROM tourservice WHERE jb_id=?",orderId);
|
||||
int count=quote.costAllocations().size();
|
||||
for(int i=0;i<count;i++)for(var component:quote.components()) {
|
||||
// Discounts are already included. Persist cents exactly as shown in the recipient preview.
|
||||
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,?,0,?,?,0)",
|
||||
orderId,quote.costAllocations().get(i).costCenterId(),component.sequence(),clip(component.name(),50),clip(component.type(),50),portion(component.net(),count,i));
|
||||
}
|
||||
metadata(orderId,"ng_cost_split",json(quote.costAllocations()));
|
||||
}
|
||||
List<Long> children(LegacyUser user,Request request,OrderEntryService.Prepared prepared,long parent,Map<String,Object> parentJob,Quote quote) {
|
||||
if(quote.courierAllocations()==null || quote.courierAllocations().isEmpty())return List.of();
|
||||
var ids=new ArrayList<Long>();int index=0;
|
||||
var tours=db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort",parent);
|
||||
for(var allocation:quote.courierAllocations()) {
|
||||
long positive=child(user,prepared,parent,parent,allocation,1,allocation.courierId(),allocation.sid(),parentJob,quote,tours);
|
||||
long negative=child(user,prepared,parent,positive,allocation,-1,id(prepared.vehicle(),"cr_id"),text(prepared.vehicle(),"crvh_sid"),parentJob,quote,tours);
|
||||
ids.add(positive);ids.add(negative);
|
||||
if(index++==0 && positive(request.toll())) {metadata(positive,"jb_toll",request.toll().toPlainString());metadata(negative,"jb_toll",request.toll().negate().toPlainString());}
|
||||
}
|
||||
db.update("UPDATE job SET jb_id_parent=-1,jb_cr_price=? WHERE jb_id=? AND hq_id=?",quote.courierNet(),parent,user.headquartersId());
|
||||
metadata(parent,"ng_courier_children",json(ids));return ids;
|
||||
}
|
||||
private long child(LegacyUser user,OrderEntryService.Prepared prepared,long original,long parent,CourierAllocation item,int sign,long courier,String sid,Map<String,Object> parentJob,Quote quote,List<Map<String,Object>> tours) {
|
||||
BigDecimal factor=BigDecimal.valueOf(sign);
|
||||
var job=fields("hq_id",user.headquartersId(),"vht_id",parentJob.get("vht_id"),"csc_id_payer",item.payerId(),"jb_payment",parentJob.get("jb_payment"),
|
||||
"jb_status",2,"jb_autoranking",0,"emp_id",user.userId(),"jb_postage",0,"jb_invmode",quote.invoiceMode(),"jb_freetext_1","",
|
||||
"jb_incomplete",parentJob.get("jb_incomplete"),"jb_sales_tax_rate",item.taxRate(),"jb_sales_tax_rate_sign",item.taxSign(),
|
||||
"jb_ordertime",prepared.at(),"jb_taketime",prepared.at(),"jb_finishtime",prepared.at(),"jb_booktime",prepared.now(),"jb_createtime",prepared.now(),
|
||||
"jb_totalprice",item.net().multiply(factor),"jb_cr_price",item.net().multiply(factor),"jb_fixprice",item.base().multiply(factor),
|
||||
"jb_subtotalprice",item.subtotal().multiply(factor),"jb_serviceprice",item.service().multiply(factor),"jb_markup",quote.markup(),
|
||||
"cr_id",courier,"cr_sid",sid,"cr_id_order",courier,"jb_id_parent",parent,"jb_tourdata",parentJob.get("jb_tourdata"),
|
||||
"jb_invtext","Unternehmerverrechnung zu Auftrag Nr. "+original,"jb_invtext_cr",parentJob.get("jb_invtext_cr"));
|
||||
long id=db.insert("job",job);
|
||||
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,0,0,'Fixpreis',0,'',?,?)",
|
||||
id,item.payerId(),markup(item.base(),quote.markup()).multiply(factor),item.discount());
|
||||
if(item.service().signum()>0)db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,0,0,'Servicepreis',0,'',?,0)",id,item.payerId(),item.service().multiply(factor));
|
||||
for(var tour:tours) {var copy=new LinkedHashMap<>(tour);copy.remove("tr_id");copy.put("jb_id",id);db.insert("tour",copy);}
|
||||
metadata(id,"jb_invtext_dirty","1");return id;
|
||||
}
|
||||
List<Long> childIds(long parent) {
|
||||
var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_courier_children'",parent);
|
||||
if(rows.isEmpty())return List.of();
|
||||
try{return JSON.readValue(text(rows.getFirst(),"gdc_content"),JSON.getTypeFactory().constructCollectionType(List.class,Long.class));}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
private void metadata(long id,String name,String value){db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname",name,"gdc_content",value,"gdc_context",""));}
|
||||
private static String clip(String value,int limit){return value.substring(0,Math.min(value.length(),limit));}
|
||||
private static String json(Object value){try{return JSON.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.time.LocalDateTime;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Durable outbox in an existing InnoDB table; the legacy log itself may be MyISAM. */
|
||||
@Service
|
||||
@EnableScheduling
|
||||
public class OrderEntryAudit {
|
||||
private final DatabaseService db;
|
||||
private final ObjectMapper json;
|
||||
private final String logTable;
|
||||
record Event(long userId,long headquartersId,String at,String description,int operation) {}
|
||||
public OrderEntryAudit(DatabaseService db,ObjectMapper json,@Value("${app.history-schema:phoenix_log}") String schema) {
|
||||
this.db=db;this.json=json;
|
||||
if(!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");
|
||||
logTable="`"+schema+"`.log";
|
||||
}
|
||||
void enqueue(long orderId,LegacyUser user,LocalDateTime at,OrderEntryModels.Mode mode,String requestId) {
|
||||
String label=switch(mode){case IMMEDIATE->"Erfassung";case RESERVATION->"Vorbestellung";case RECURRING->"Dauerauftrag";case RETROSPECTIVE->"Nacherfassung";};
|
||||
try {
|
||||
String content=json.writeValueAsString(new Event(user.userId(),user.headquartersId(),at.toString(),label+" (VoTiAn NG)|NG_REQUEST="+requestId,1));
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_creation_log","gdc_content",content,"gdc_context","PENDING"));
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Protokoll konnte nicht vorbereitet werden.",e);}
|
||||
}
|
||||
void enqueueLimit(long orderId,LegacyUser user,LocalDateTime at,String requestId,java.math.BigDecimal limit,java.math.BigDecimal price) {
|
||||
try {
|
||||
String content=json.writeValueAsString(new Event(user.userId(),user.headquartersId(),at.toString(),
|
||||
"BLOCK="+limit+"%|OVERRIDE="+price+"|NG_REQUEST="+requestId,154));
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_creation_log","gdc_content",content,"gdc_context","PENDING"));
|
||||
}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException(e);}
|
||||
}
|
||||
void tryPublish(long orderId) {
|
||||
try { publish(orderId); }
|
||||
catch(RuntimeException e) {
|
||||
LoggerFactory.getLogger(getClass()).warn("Ein gesicherter Auftragshistorieneintrag wird später erneut übertragen.");
|
||||
}
|
||||
}
|
||||
void publish(long orderId) {
|
||||
db.writeTransaction(() -> {
|
||||
// Serialize publishers across instances. A MyISAM insert can survive a failed commit;
|
||||
// the stable description marker lets the next attempt find and reuse that log entry.
|
||||
if(db.lockRows("SELECT jb_id FROM job WHERE jb_id=?",orderId).isEmpty())return null;
|
||||
var rows=db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_creation_log' AND gdc_context='PENDING'",orderId);
|
||||
for(var row:rows) {
|
||||
Event event;
|
||||
try {event=json.readValue(text(row,"gdc_content"),Event.class);}
|
||||
catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Ungültiger Protokollauftrag.",e);}
|
||||
int operation=event.operation()==154?154:1;
|
||||
var existing=db.queryForList("SELECT log_id FROM "+logTable+" WHERE jb_id=? AND logo_id=? AND logo_description=? LIMIT 1",orderId,operation,event.description());
|
||||
if(existing.isEmpty())db.update("INSERT INTO "+logTable+" (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,?,?,0,'',0,0,0,0,?,?)",
|
||||
operation,event.headquartersId(),orderId,event.userId(),event.description(),LocalDateTime.parse(event.at()));
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=? AND gdc_context='PENDING'",id(row,"gdc_id"));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
@Scheduled(initialDelay=60000,fixedDelay=30000)
|
||||
public void retryPending() {
|
||||
try {
|
||||
var rows=db.queryForList("SELECT gdc_obj_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_gen_fieldname='ng_creation_log' AND gdc_context='PENDING' ORDER BY gdc_id LIMIT 50");
|
||||
for(var row:rows)tryPublish(id(row,"gdc_obj_id"));
|
||||
} catch(RuntimeException e) { /* A disconnected DB is already reported by /api/system. Outbox entries remain pending. */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
|
||||
/** Entry lookups have their own right (7); customer/driver administration rights are not required. */
|
||||
@Service
|
||||
public class OrderEntryCatalog {
|
||||
private final DatabaseService db;
|
||||
static final String COST_CENTER_FROM = """
|
||||
FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id
|
||||
WHERE cs.hq_id=? AND cc.csc_visible=1 AND COALESCE(cc.csc_blocked,0)=0
|
||||
AND cmp.cmp_visible=1 AND cmp.cmp_authenticated=1 AND COALESCE(cmp.cmp_archived,0)=0
|
||||
""";
|
||||
static final String COST_CENTER_SELECT = """
|
||||
SELECT cc.csc_id,cc.csc_name,cc.csc_is_extern,cs.cs_id,cs.cs_eid,cmp.cmp_comp,
|
||||
cs.csc_id_payer,cs.cs_discount,cs.cs_fixprice_discount,cs.cs_invmode,cs.cs_jbcond,
|
||||
cs.cs_jobbatch,cs.cs_markup,cs.cs_markup_prov,cmp.cmp_postage,cs.hq_id,
|
||||
cs.cs_jb_commission_no,cs.cs_commission_no,cs.cs_id_parent,cmp.cmp_sustainability_email,
|
||||
cs.cs_filter,cmp.tx_id,cmp.cmp_stax_idno
|
||||
""";
|
||||
static final String VEHICLE_FROM = """
|
||||
FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id
|
||||
JOIN user u ON u.usr_id=cr.usr_id JOIN company cmp ON cmp.cmp_id=cr.cmp_id
|
||||
WHERE cr.hq_id=? AND u.hq_id=cr.hq_id AND cmp.cmp_visible=1
|
||||
AND cmp.cmp_authenticated=1 AND COALESCE(cmp.cmp_archived,0)=0
|
||||
AND COALESCE(cr.cr_app_blocked,0)=0 AND cv.crvh_sid<>''
|
||||
""";
|
||||
public OrderEntryCatalog(DatabaseService db) { this.db=db; }
|
||||
|
||||
public Options options(LegacyUser user) {
|
||||
user.require(7);
|
||||
return db.readTransaction(() -> new Options(
|
||||
db.queryForList("SELECT mt_sort,mt_value FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort")
|
||||
.stream().map(r -> new Option(id(r,"mt_sort"),text(r,"mt_value"))).toList(),
|
||||
db.queryForList("SELECT tx_id,tx_sign,tx_value FROM tax ORDER BY tx_id").stream()
|
||||
.map(r -> new Tax(id(r,"tx_id"),text(r,"tx_sign"),decimal(r,"tx_value"))).toList(),
|
||||
List.of(new Option(1,"Werktags"),new Option(2,"Werktags mit Samstag"),new Option(3,"Täglich"),
|
||||
new Option(4,"Wöchentlich"),new Option(5,"Vierzehntäglich"),new Option(6,"Monatlich"),
|
||||
new Option(7,"Zum Monatsende"),new Option(8,"15. und Monatsende"),new Option(9,"Feiertags"),
|
||||
new Option(10,"1. und 16."),new Option(11,"Vierwöchentlich")),
|
||||
user.hasRight(38),user.hasRight(39),Arrays.stream(parameter(0,"EU_COUNTRYCODES").split("[^A-Z]+")).filter(v -> v.matches("[A-Z]{2}")).toList()));
|
||||
}
|
||||
|
||||
public List<CostCenter> costCenters(LegacyUser user,String query) {
|
||||
user.require(7);
|
||||
String term=search(query);
|
||||
return db.queryForList(COST_CENTER_SELECT+COST_CENTER_FROM+
|
||||
" AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR cc.csc_name LIKE ? OR CAST(cc.csc_id AS CHAR)=?) ORDER BY cmp.cmp_comp,cc.csc_name,cc.csc_id LIMIT 50",
|
||||
user.headquartersId(),term,term,term,query==null?"":query.trim()).stream()
|
||||
.map(r -> new CostCenter(id(r,"csc_id"),id(r,"cs_id"),text(r,"cs_eid"),text(r,"cmp_comp"),
|
||||
text(r,"csc_name"),nullableId(r,"csc_id_payer"),number(r,"cs_fixprice_discount"),nullableId(r,"tx_id"))).toList();
|
||||
}
|
||||
|
||||
Map<String,Object> costCenter(LegacyUser user,long costCenterId,boolean payer) {
|
||||
var result=one(db.queryForList(COST_CENTER_SELECT+COST_CENTER_FROM+" AND cc.csc_id=?",user.headquartersId(),costCenterId),
|
||||
"Kostenstelle ist gesperrt, nicht freigegeben oder gehört zu einer anderen Niederlassung.");
|
||||
if (payer && id(result,"csc_is_extern")==1)
|
||||
throw invalid("Eine externe Adresskostenstelle kann nicht als Rechnungszahler verwendet werden.");
|
||||
return result;
|
||||
}
|
||||
|
||||
long calculatorPayer(LegacyUser user) {
|
||||
user.require(7);user.require(38);
|
||||
String configured=parameter(user.headquartersId(),"CSC_ID_PAYER_CALCULATOR");
|
||||
if(!configured.matches("[1-9][0-9]*"))throw invalid("Die Verrechnungskostenstelle CSC_ID_PAYER_CALCULATOR fehlt.");
|
||||
// This exact configured technical account is intentionally hidden/unapproved in PHP.
|
||||
// Never relax the normal payer search or accept a caller-selected hidden cost center.
|
||||
var row=one(db.queryForList("SELECT cc.csc_id FROM costcenter cc JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cc.csc_id=? AND cs.hq_id=? AND COALESCE(cc.csc_blocked,0)=0 AND cc.csc_is_extern=0 AND COALESCE(cmp.cmp_archived,0)=0",
|
||||
Long.parseLong(configured),user.headquartersId()),"Die konfigurierte Verrechnungskostenstelle fehlt, ist gesperrt oder gehört zu einer anderen Niederlassung.");
|
||||
return id(row,"csc_id");
|
||||
}
|
||||
|
||||
public List<Address> addresses(LegacyUser user,long costCenterId) {
|
||||
user.require(7);
|
||||
return db.readTransaction(() -> {
|
||||
costCenter(user,costCenterId,false);
|
||||
return db.queryForList("""
|
||||
SELECT ca.adt_id,ca.csc_id,ca.cscad_comp,ca.cscad_hsno,ca.cscad_person,ca.cscad_phone,ca.cscad_remark,
|
||||
a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country
|
||||
FROM costcenteraddress ca JOIN address a ON a.ad_id=ca.ad_id
|
||||
WHERE ca.csc_id=? AND ca.adt_id=4 ORDER BY ca.adt_id
|
||||
""",costCenterId).stream().map(r -> new Address(id(r,"adt_id"),costCenterId,text(r,"cscad_comp"),
|
||||
text(r,"ad_street"),text(r,"cscad_hsno"),text(r,"ad_zipcode"),text(r,"ad_city"),
|
||||
text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),text(r,"cscad_person"),text(r,"cscad_phone"),text(r,"cscad_remark"))).toList();
|
||||
});
|
||||
}
|
||||
|
||||
public CustomerDefaults defaults(LegacyUser user,long costCenterId) {
|
||||
user.require(7);
|
||||
return db.readTransaction(() -> {
|
||||
var center=costCenter(user,costCenterId,false);
|
||||
var mail=db.queryForList("SELECT u.usr_inv_email FROM customer cs JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id WHERE cs.cs_id=?",id(center,"cs_id"));
|
||||
return new CustomerDefaults(mail.isEmpty()?"":text(mail.getFirst(),"usr_inv_email"),text(center,"cmp_stax_idno"),
|
||||
id(center,"cs_jb_commission_no")==1 && enabled(user.headquartersId(),"MASK_COMMISSION_NO_MANDATORY"),
|
||||
enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(center,"cs_id")));
|
||||
});
|
||||
}
|
||||
|
||||
public List<CourierFilter> filters(LegacyUser user) {
|
||||
user.require(7);
|
||||
return db.queryForList("SELECT crf_short,crf_longtext,crf_status FROM courierfilter ORDER BY crf_short").stream()
|
||||
.map(r -> new CourierFilter(text(r,"crf_short"),text(r,"crf_longtext"),id(r,"crf_status")==1)).toList();
|
||||
}
|
||||
|
||||
public List<Option> billingPostalCodes(LegacyUser user,String query) {
|
||||
user.require(7);
|
||||
return db.queryForList("SELECT srvp_id,srvp_plz FROM serviceplz WHERE srvp_plz LIKE ? ORDER BY srvp_plz LIMIT 50",search(query))
|
||||
.stream().map(r -> new Option(id(r,"srvp_id"),text(r,"srvp_plz"))).toList();
|
||||
}
|
||||
|
||||
public List<ServiceOption> services(LegacyUser user,long costCenterId) {
|
||||
user.require(7);var center=costCenter(user,costCenterId,false);
|
||||
long cs=id(center,"cs_id");
|
||||
var selected=db.queryForList("SELECT srv_id FROM customerservice WHERE cs_id=? UNION SELECT srv_id FROM service WHERE cs_id=?",cs,cs);
|
||||
boolean specific=!selected.isEmpty();
|
||||
var types=specific?db.queryForList("SELECT srvt_id FROM customerservicetype WHERE cs_id=? UNION SELECT srvt_id FROM servicetype WHERE cs_id=?",cs,cs):List.<Map<String,Object>>of();
|
||||
if(specific && types.isEmpty())return List.of();
|
||||
var services=selected.stream().map(r -> id(r,"srv_id")).toList();
|
||||
var typeIds=types.stream().map(r -> id(r,"srvt_id")).toList();
|
||||
return db.queryForList("""
|
||||
SELECT DISTINCT s.srv_id,s.srv_name,t.srvt_id,t.srvt_name,s.srv_mode,t.srvt_mode FROM servicehistory h
|
||||
JOIN service s ON s.srv_id=h.srv_id JOIN servicetype t ON t.srvt_id=h.srvt_id
|
||||
WHERE h.hq_id=? AND h.cs_id IN (0,?) AND s.srv_name<>'DELETE' AND t.srvt_name<>'DELETE'
|
||||
ORDER BY s.srv_name,t.srvt_name
|
||||
""",user.headquartersId(),cs).stream()
|
||||
.filter(r -> specific?services.contains(id(r,"srv_id")) && typeIds.contains(id(r,"srvt_id")):id(r,"srv_mode")==1 && id(r,"srvt_mode")==1)
|
||||
.map(r -> new ServiceOption(id(r,"srv_id"),id(r,"srvt_id"),text(r,"srv_name"),text(r,"srvt_name"))).toList();
|
||||
}
|
||||
|
||||
public List<Address> searchAddresses(LegacyUser user,String query) {
|
||||
user.require(7);if(query==null || query.trim().length()<3)return List.of();
|
||||
// Saved addresses already visible within this branch, including external delivery addresses.
|
||||
return db.queryForList("""
|
||||
SELECT DISTINCT a.ad_id,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM address a
|
||||
JOIN tour t ON t.ad_id=a.ad_id JOIN job j ON j.jb_id=t.jb_id
|
||||
WHERE j.hq_id=? AND CONCAT(a.ad_street,' ',a.ad_zipcode,' ',a.ad_city) LIKE ?
|
||||
ORDER BY a.ad_street,a.ad_zipcode LIMIT 50
|
||||
""",user.headquartersId(),search(query)).stream()
|
||||
.map(r -> new Address(id(r,"ad_id"),0,"",text(r,"ad_street"),"",text(r,"ad_zipcode"),text(r,"ad_city"),
|
||||
text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),"","","")).toList();
|
||||
}
|
||||
|
||||
public List<Vehicle> vehicles(LegacyUser user,String query) {
|
||||
user.require(7);
|
||||
String term=search(query);
|
||||
return db.queryForList("SELECT cv.crvh_id,cv.cr_id,cv.crvh_sid,cv.vht_id,cv.crvh_vh_sign,cmp.cmp_comp "+VEHICLE_FROM+
|
||||
" AND (cv.crvh_sid LIKE ? OR cmp.cmp_comp LIKE ? OR cv.crvh_vh_sign LIKE ?) ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 50",
|
||||
user.headquartersId(),term,term,term).stream().map(r -> new Vehicle(id(r,"crvh_id"),id(r,"cr_id"),
|
||||
text(r,"crvh_sid"),text(r,"cmp_comp"),(int)id(r,"vht_id"),text(r,"crvh_vh_sign"))).toList();
|
||||
}
|
||||
|
||||
Map<String,Object> vehicle(LegacyUser user,long vehicleId) {
|
||||
return one(db.queryForList("SELECT cv.crvh_id,cv.cr_id,cv.crvh_sid,cv.vht_id,cv.crvh_payload,cv.crvh_env_badge_level,cr.cr_sid AS current_sid "+VEHICLE_FROM+
|
||||
" AND cv.crvh_id=?",user.headquartersId(),vehicleId),"Fahrzeug ist nicht verfügbar oder gehört zu einer anderen Niederlassung.");
|
||||
}
|
||||
|
||||
Map<String,Object> headquarters(LegacyUser user) {
|
||||
return one(db.queryForList("SELECT hq_id,hq_workmode,hq_invmode FROM headquarters WHERE hq_id=? AND COALESCE(hq_disabled,0)=0",
|
||||
user.headquartersId()),"Die Niederlassung ist nicht für die Auftragserfassung freigegeben.");
|
||||
}
|
||||
|
||||
String parameter(long headquartersId,String key) {
|
||||
var rows=db.queryForList("SELECT par_value FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key=?",headquartersId,key);
|
||||
if(rows.size()>1) throw invalid("Mehrdeutige Einstellung im Bestand: "+key);
|
||||
return rows.isEmpty()?"":text(rows.getFirst(),"par_value");
|
||||
}
|
||||
|
||||
boolean enabled(long hq,String key) { return parameter(hq,key).equals("1"); }
|
||||
static String search(String query) {
|
||||
if(query!=null && query.length()>100) throw invalid("Der Suchtext darf höchstens 100 Zeichen enthalten.");
|
||||
return "%"+(query==null?"":query.trim())+"%";
|
||||
}
|
||||
static Map<String,Object> one(List<Map<String,Object>> rows,String message) {
|
||||
if(rows.size()!=1) throw invalid(message);
|
||||
return rows.getFirst();
|
||||
}
|
||||
static BigDecimal number(Map<String,Object> row,String key) {
|
||||
BigDecimal value=decimal(row,key);return value==null?BigDecimal.ZERO:value;
|
||||
}
|
||||
static ResponseStatusException invalid(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
public class OrderEntryController {
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final OrderEntryService service;
|
||||
private final OrderPhotoWorkflows photos;
|
||||
public OrderEntryController(OrderEntryCatalog catalog,OrderEntryService service,OrderPhotoWorkflows photos) {this.catalog=catalog;this.service=service;this.photos=photos;}
|
||||
@GetMapping("/entry/options") public Options options(Authentication auth) {return catalog.options(user(auth));}
|
||||
@GetMapping("/entry/cost-centers") public List<CostCenter> costCenters(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.costCenters(user(auth),q);}
|
||||
@GetMapping("/entry/cost-centers/{id}/addresses") public List<Address> addresses(Authentication auth,@PathVariable long id) {return catalog.addresses(user(auth),id);}
|
||||
@GetMapping("/entry/cost-centers/{id}/photo-workflows") public PhotoOptions photoWorkflows(Authentication auth,@PathVariable long id) {return photos.options(user(auth),id);}
|
||||
@GetMapping("/entry/vehicles") public List<Vehicle> vehicles(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.vehicles(user(auth),q);}
|
||||
@PostMapping("/quote") public Quote quote(Authentication auth,@Valid @RequestBody Request request) {return service.quote(user(auth),request);}
|
||||
@PostMapping public ResponseEntity<Created> create(Authentication auth,@Valid @RequestBody Request request) {
|
||||
var result=service.create(user(auth),request);
|
||||
return ResponseEntity.status(result.replayed()?200:201).location(URI.create("/api/orders/"+result.id())).body(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.*;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Additional entry workflows, using only existing legacy tables and the caller's transaction. */
|
||||
final class OrderEntryExtras {
|
||||
private static final ObjectMapper JSON=new ObjectMapper().findAndRegisterModules();
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
OrderEntryExtras(DatabaseService db,OrderEntryCatalog catalog) {this.db=db;this.catalog=catalog;}
|
||||
|
||||
void validate(LegacyUser user,Request request,Map<String,Object> payer) {
|
||||
if(id(payer,"cs_jb_commission_no")==1 && catalog.enabled(user.headquartersId(),"MASK_COMMISSION_NO_MANDATORY") && blank(request.reference()))
|
||||
throw invalid("Für diesen Kunden ist eine Auftragsreferenz / Kommissionsnummer erforderlich.");
|
||||
if(catalog.enabled(0,"CUSTOMER_MASK_CALCULATOR_USAGE_ONLY_"+id(payer,"cs_id")) && request.services().isEmpty())
|
||||
throw invalid("Dieser Kunde benötigt eine Kalkulation mit Leistungspositionen.");
|
||||
if(request.payment()==Payment.INVOICE && !request.stops().getFirst().country().equals("DE") && text(payer,"cmp_stax_idno").isBlank())
|
||||
throw invalid("Für eine Auslandsabholung auf Rechnung fehlt die Umsatzsteuer-ID des Zahlers im Kundenstamm.");
|
||||
if(!request.services().isEmpty() && request.pricing().mode()!=PriceMode.SERVICES)throw invalid("Für Leistungspositionen bitte die Preisermittlung Leistungskalkulation wählen.");
|
||||
var filters=catalog.filters(user).stream().map(CourierFilter::code).toList();
|
||||
if(!filters.containsAll(request.courierFilters()))throw invalid("Ein gewählter Kurierfilter ist nicht mehr verfügbar.");
|
||||
for(Station stop:request.stops()) {
|
||||
if(stop.billingPostalId()!=null && db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_id=?",stop.billingPostalId()).isEmpty())
|
||||
throw invalid("Die gewählte Rechnungs-PLZ ist nicht mehr verfügbar.");
|
||||
for(Attachment file:stop.attachments())validateAttachment(file);
|
||||
}
|
||||
if(request.insurance()!=null)insuranceCourier(user,request);
|
||||
if(request.sourceOrderId()!=null)requireOrder(user,request.sourceOrderId());
|
||||
if(request.overwriteTemplateId()!=null && blank(request.tourName()))throw invalid("Bitte einen Namen für die gespeicherte Tour angeben.");
|
||||
if(request.updateInvoiceEmail() && blank(request.invoiceEmail()))throw invalid("Bitte eine Rechnungsmailadresse angeben.");
|
||||
}
|
||||
void globalExclusions(LegacyUser user,Request request,long orderId) {
|
||||
var excluded=new TreeSet<Long>();StringBuilder context=new StringBuilder();
|
||||
String home=catalog.parameter(0,"GLOBAL_NATIONALITY_DEFAULT");if(home.isBlank())home="DE";
|
||||
Map<String,Set<String>> whitelist=new HashMap<>();
|
||||
String configured=catalog.parameter(0,"GLOBAL_NATIONALITY_WHITELIST");
|
||||
for(String part:configured.split("\\|"))if(!part.isBlank()) {
|
||||
String[] pair=part.split("=",2);
|
||||
if(pair.length!=2 || !pair[0].trim().matches("[A-Z]{2}"))throw invalid("Die Länderfreigaben für die Kurierverteilung sind ungültig.");
|
||||
whitelist.put(pair[0].trim(),new HashSet<>(Arrays.asList(pair[1].split(","))));
|
||||
}
|
||||
var countries=request.stops().stream().map(Station::country).distinct().toList();
|
||||
var couriers=db.queryForList("""
|
||||
SELECT DISTINCT c.cr_id,u.usr_country FROM courier c JOIN company cmp ON cmp.cmp_id=c.cmp_id
|
||||
JOIN couriervehicle cv ON cv.cr_id=c.cr_id JOIN user u ON u.usr_id=c.usr_id
|
||||
WHERE c.hq_id=? AND cmp.cmp_authenticated=1 AND cmp.cmp_visible=1 AND cmp.cmp_archived=0
|
||||
""",user.headquartersId());
|
||||
for(String country:countries)if(!country.equals(home) && whitelist.containsKey(country)) {
|
||||
for(var row:couriers)if(!whitelist.get(country).contains(text(row,"usr_country")))excluded.add(id(row,"cr_id"));
|
||||
context.append("[NATION_CHK:").append(country).append("]");
|
||||
}
|
||||
String eu=catalog.parameter(0,"EU_COUNTRYCODES");final String domestic=home;
|
||||
if(countries.stream().anyMatch(c -> !c.equals(domestic) && eu.contains(c))) {
|
||||
var rows=db.queryForList("""
|
||||
SELECT DISTINCT c.cr_id FROM courier c JOIN company cmp ON cmp.cmp_id=c.cmp_id
|
||||
JOIN couriervehicle cv ON cv.cr_id=c.cr_id AND cv.crvh_sid=c.cr_sid
|
||||
WHERE c.hq_id=? AND cmp.cmp_authenticated=1 AND cmp.cmp_visible=1 AND cmp.cmp_archived=0
|
||||
AND cv.crvh_totalweight>=2800 AND cv.crvh_filter NOT LIKE '%,EG,%' AND c.cr_filter NOT LIKE '%,EG,%'
|
||||
""",user.headquartersId());
|
||||
for(var row:rows)excluded.add(id(row,"cr_id"));
|
||||
context.append("[3RD_COUNTRY_CHK]");
|
||||
}
|
||||
if(!excluded.isEmpty())metadata(orderId,"global_job_excluded_cr_list",excluded.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(",")),context.toString());
|
||||
}
|
||||
private static boolean blank(String value) {return value==null || value.isBlank();}
|
||||
void replaceTemplate(LegacyUser user,Request request) {
|
||||
if(blank(request.tourName()))return;
|
||||
// A shared payer lock also serializes first-time names (no existing template row to lock).
|
||||
if(db.lockRows("SELECT csc_id FROM costcenter WHERE csc_id=?",request.payerCostCenterId()).isEmpty())throw invalid("Die zahlende Kostenstelle ist nicht mehr verfügbar.");
|
||||
var matches=db.queryForList("SELECT jb_id FROM job WHERE hq_id=? AND jb_tourname=? AND (csc_id_payer=? OR csc_id_payer_cash=?)",
|
||||
user.headquartersId(),request.tourName().trim(),request.payerCostCenterId(),request.payerCostCenterId());
|
||||
if(!matches.isEmpty()) {
|
||||
if(matches.size()!=1 || !Objects.equals(request.overwriteTemplateId(),id(matches.getFirst(),"jb_id")))
|
||||
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Dieser Tourname ist bereits vergeben. Bitte die vorhandene Tour laden und das Überschreiben ausdrücklich auswählen.");
|
||||
db.update("UPDATE job SET jb_tourname=NULL WHERE jb_id=? AND hq_id=?",request.overwriteTemplateId(),user.headquartersId());
|
||||
} else if(request.overwriteTemplateId()!=null)throw invalid("Die zu überschreibende Tour wurde inzwischen umbenannt. Bitte neu laden.");
|
||||
}
|
||||
long persist(LegacyUser user,Request request,OrderEntryService.Prepared prepared,long orderId) {
|
||||
if(request.toll()!=null && request.toll().signum()>0)metadata(orderId,"jb_toll",request.toll().toPlainString(),"");
|
||||
if(!blank(request.invoiceOnlyText()))metadata(orderId,"jb_hiddenFreetext_1",request.invoiceOnlyText(),"");
|
||||
if(request.origin()==6)metadata(orderId,"jb_origin_other",request.originOther().trim(),"");
|
||||
if(request.sourceOrderId()!=null)metadata(orderId,"ng_source_order",request.sourceOrderId().toString(),"");
|
||||
if(request.acceptUnverifiedAddresses())metadata(orderId,"ng_address_override","1",Long.toString(user.userId()));
|
||||
String seed=json(request);
|
||||
// TEXT holds at most 64 KiB; full multi-stop drafts can be larger.
|
||||
for(int start=0,index=0;start<seed.length();index++) {
|
||||
int end=seed.offsetByCodePoints(start,Math.min(12000,seed.codePointCount(start,seed.length())));
|
||||
metadata(orderId,"ng_entry_seed",seed.substring(start,end),Integer.toString(index));start=end;
|
||||
}
|
||||
if(request.updateInvoiceEmail()) {
|
||||
// The PHP helper also marks the company for master-data export.
|
||||
var account=one(db.queryForList("SELECT u.usr_id,cs.cmp_id FROM customer cs JOIN employee e ON e.emp_id=cs.cs_admin JOIN user u ON u.usr_id=e.usr_id WHERE cs.cs_id=?",id(prepared.payer(),"cs_id")),"Für den Kunden fehlt das Rechnungsmail-Benutzerkonto.");
|
||||
var engines=db.queryForList("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='company'");
|
||||
if(engines.size()!=1 || !text(engines.getFirst(),"ENGINE").equalsIgnoreCase("InnoDB"))throw invalid("Die Rechnungsmail kann nur mit transaktionsfähigem Kundenstamm geändert werden.");
|
||||
db.update("UPDATE user SET usr_inv_email=? WHERE usr_id=?",request.invoiceEmail().trim(),id(account,"usr_id"));
|
||||
db.update("UPDATE company SET cmp_modify_status=2 WHERE cmp_id=? AND cmp_modify_status=0",id(account,"cmp_id"));
|
||||
metadata(orderId,"ng_invoice_email_change",request.invoiceEmail().trim(),user.userId()+"|"+id(account,"usr_id"));
|
||||
}
|
||||
return request.insurance()!=null?persistInsurance(user,request,prepared,orderId):0;
|
||||
}
|
||||
private void validateAttachment(Attachment file) {
|
||||
String configured=catalog.parameter(0,"URL_DOC_UPLOAD");
|
||||
try {
|
||||
URI expected=URI.create(configured),url=URI.create(file.viewUrl());
|
||||
if(!Set.of("http","https").contains(expected.getScheme()) || expected.getHost()==null || url.getUserInfo()!=null ||
|
||||
!Objects.equals(expected.getScheme(),url.getScheme()) || !Objects.equals(expected.getHost(),url.getHost()) || expected.getPort()!=url.getPort() || file.viewUrl().contains("|"))throw new IllegalArgumentException();
|
||||
} catch(IllegalArgumentException e) {throw invalid("Der Dateilink gehört nicht zum konfigurierten Upload-Dienst.");}
|
||||
}
|
||||
void attachments(long tourId,List<Attachment> files) {
|
||||
int i=0;
|
||||
for(Attachment file:files)db.insert("genericdatacontainer",fields("gdc_obj_type","tr","gdc_obj_id",tourId,"gdc_gen_fieldname","file_upload",
|
||||
"gdc_content",file.filename(),"gdc_context",(file.remark()==null?"":file.remark().replace('|',' '))+"|"+file.viewUrl()+"|"+(++i)));
|
||||
}
|
||||
long linkedInsurance(long orderId) {
|
||||
var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_id_insurance'",orderId);
|
||||
return rows.isEmpty()?0:Long.parseLong(text(rows.getFirst(),"gdc_content"));
|
||||
}
|
||||
void metadata(long orderId,String key,String content,String context) {
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname",key,"gdc_content",content,"gdc_context",context));
|
||||
}
|
||||
static String json(Object value) {try {return JSON.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("Auftragsdaten konnten nicht gesichert werden.",e);}}
|
||||
Request copyCommand(Request request,int index) {
|
||||
ObjectNode tree=JSON.valueToTree(request);
|
||||
tree.put("requestId",UUID.nameUUIDFromBytes((request.requestId()+":"+index).getBytes(StandardCharsets.UTF_8)).toString());
|
||||
tree.put("copies",1);tree.putNull("tourName");tree.putNull("overwriteTemplateId");tree.put("updateInvoiceEmail",false);
|
||||
try {return JSON.treeToValue(tree,Request.class);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
Request seriesCommand(Request request,int index,java.time.LocalDateTime at,boolean keepCommission) {
|
||||
ObjectNode tree=JSON.valueToTree(copyCommand(request,index));tree.put("allOccurrences",false);
|
||||
tree.set("pickupAt",JSON.valueToTree(at));
|
||||
if(request.warningAt()!=null)tree.set("warningAt",JSON.valueToTree(request.warningAt().plus(java.time.Duration.between(request.pickupAt(),at))));
|
||||
if(request.acceptUntil()!=null)tree.set("acceptUntil",JSON.valueToTree(request.acceptUntil().plus(java.time.Duration.between(request.pickupAt(),at))));
|
||||
if(!keepCommission)for(var stop:tree.withArray("stops"))((ObjectNode)stop).put("reference","");
|
||||
try {return JSON.treeToValue(tree,Request.class);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
Quote withSchedule(Quote first,List<ScheduleItem> schedule,boolean limitExceeded) {
|
||||
ObjectNode tree=JSON.valueToTree(first);tree.set("schedule",JSON.valueToTree(schedule));tree.put("courierLimitExceeded",limitExceeded);
|
||||
tree.put("fingerprint",LegacyOrderPricing.hash(first.fingerprint()+"|"+limitExceeded+"|"+json(schedule)));
|
||||
try {return JSON.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
void saveGroup(long id,List<Long> ids) {metadata(id,"ng_created_group",json(ids),"1");}
|
||||
List<Long> group(long id) {
|
||||
var rows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_created_group'",id);
|
||||
if(rows.isEmpty())return List.of(id);
|
||||
try {return JSON.readValue(text(rows.getFirst(),"gdc_content"),JSON.getTypeFactory().constructCollectionType(List.class,Long.class));}
|
||||
catch(Exception e){throw new IllegalStateException("Die gespeicherte Auftragsgruppe konnte nicht gelesen werden.",e);}
|
||||
}
|
||||
Map<String,Object> requireOrder(LegacyUser user,long orderId) {
|
||||
user.require(7);
|
||||
return one(db.queryForList("SELECT * FROM job WHERE jb_id=? AND hq_id=?",orderId,user.headquartersId()),"Der Auftrag ist in dieser Niederlassung nicht verfügbar.");
|
||||
}
|
||||
String calculatorText(LegacyUser user,Request request,Quote quote) {
|
||||
var options=catalog.services(user,request.payerCostCenterId());
|
||||
var names=new ArrayList<String>();var types=new ArrayList<String>();var amounts=new ArrayList<String>();var prices=new ArrayList<String>();
|
||||
for(int i=0;i<request.services().size();i++) {
|
||||
var item=request.services().get(i);var option=options.stream().filter(o -> o.serviceId()==item.serviceId() && o.typeId()==item.typeId()).findFirst().orElseThrow(() -> invalid("Leistungsposition nicht mehr verfügbar."));
|
||||
if(option.service().contains("¹") || option.service().contains("²") || option.type().contains("¹") || option.type().contains("²"))throw invalid("Der Leistungsname enthält ein reserviertes Bestands-Trennzeichen.");
|
||||
names.add(option.service());types.add(option.type());amounts.add(item.quantity().stripTrailingZeros().toPlainString());prices.add(quote.components().get(i).subtotal().toPlainString());
|
||||
}
|
||||
return String.join("¹",names)+"²"+String.join("¹",types)+"²"+String.join("¹",amounts)+"²"+String.join("¹",prices);
|
||||
}
|
||||
static BigDecimal insurancePremium(Insurance insurance) {
|
||||
if(insurance==null)return BigDecimal.ZERO;
|
||||
BigDecimal rate=new BigDecimal(switch(insurance.rate()){case 1,5->"0.070";case 2->"0.175";case 3->"0.085";case 4->"0.200";default->throw invalid("Ungültiger Versicherungstarif.");});
|
||||
BigDecimal factor=new BigDecimal(insurance.rate()==3 || insurance.rate()==4?"1":"1.19");
|
||||
return insurance.goodsValue().multiply(rate).movePointLeft(2).multiply(factor).setScale(2,RoundingMode.CEILING).max(new BigDecimal("10").multiply(factor));
|
||||
}
|
||||
private Map<String,Object> insuranceCourier(LegacyUser user,Request request) {
|
||||
return one(db.queryForList("SELECT cv.cr_id,cv.crvh_sid FROM couriervehicle cv JOIN courier c ON c.cr_id=cv.cr_id JOIN headquarters h ON h.hq_id=c.hq_id WHERE h.hq_id=? AND cv.crvh_sid=CONCAT(h.hq_mnemonic,?)",
|
||||
user.headquartersId(),request.vehicleTypeId()>=7?"1777":"1888"),"Der technische Versicherungskurier fehlt für diese Niederlassung.");
|
||||
}
|
||||
private long persistInsurance(LegacyUser user,Request request,OrderEntryService.Prepared prepared,long parent) {
|
||||
Insurance insurance=request.insurance();BigDecimal premium=insurancePremium(insurance);
|
||||
var courier=insuranceCourier(user,request);
|
||||
long id=db.insert("job",fields("hq_id",user.headquartersId(),"vht_id",999,"csc_id_payer",request.payerCostCenterId(),"jb_ordertime",prepared.at(),
|
||||
"jb_reserv",0,"cr_id",id(courier,"cr_id"),"cr_sid",text(courier,"crvh_sid"),"cr_id_order",id(courier,"cr_id"),
|
||||
"jb_cr_filter","","jb_cr_filter_opt","","jb_waitstorno",0,"jb_status",insurance.specialCase()?1:2,"jb_incomplete",1,"jb_globaljob",0,
|
||||
"jb_taketime",prepared.now(),"jb_finishtime",prepared.now(),"emp_id",user.userId(),"jb_fixprice",premium,"jb_totalprice",premium,"jb_subtotalprice",premium,
|
||||
"jb_mediationarea_id",0,"jb_mediationarea_name","","jb_sales_tax_rate",19,"jb_sales_tax_rate_sign","V",
|
||||
"jb_freetext_1","Gebuchte Transportversicherung zu Auftrag "+parent+" mit "+insurance.goodsValue()+" EUR Warenwert, Tarif "+insurance.rate()+", Prämie "+premium+" EUR",
|
||||
"jb_tourdata",".|.|DE","jb_createtime",prepared.now(),"jb_id_parent",0,"jb_dispoinfo","HV zu "+parent,"jb_timeunits",2,"jb_booktime",prepared.now(),"jb_origin",8,"jb_orderer",request.orderer()));
|
||||
long address=db.insert("address",fields("ad_street","Warenwert "+insurance.goodsValue()+" Euro","ad_zipcode",".","ad_city",".","ad_country","DE"));
|
||||
db.insert("tour",fields("jb_id",id,"ad_id",address,"tr_sort",1,"tr_comp","Transportzusatzversicherung","tr_hsno",".","tr_person","","csc_id",request.payerCostCenterId(),"tr_remark","","tr_status",0,"tr_ware_from_to",0,"tr_mediationarea_id",0));
|
||||
db.insert("tourservice",fields("jb_id",id,"csc_id",request.payerCostCenterId(),"tr_sort",0,"srv_id",0,"trs_srv_name","Fixpreis","srvt_id",0,"trs_srvt_name","","trs_price",premium,"trs_discount",0));
|
||||
metadata(parent,"jb_id_insurance",Long.toString(id),insurance.goodsValue()+"|"+insurance.rate()+"|"+(insurance.specialCase()?1:0));
|
||||
metadata(id,"jb_invtext_dirty","1",prepared.now().toString());
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/entry")
|
||||
public class OrderEntryFeaturesController {
|
||||
private final OrderEntryInventory inventory;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final OrderRoutingService routing;
|
||||
public OrderEntryFeaturesController(OrderEntryInventory inventory,OrderEntryCatalog catalog,OrderRoutingService routing){this.inventory=inventory;this.catalog=catalog;this.routing=routing;}
|
||||
@GetMapping("/articles") public List<ArticleOption> articles(Authentication auth,@RequestParam(defaultValue="") String q){return inventory.articles(user(auth),q);}
|
||||
@GetMapping("/stocks") public List<StockOption> stocks(Authentication auth,@RequestParam long payerId,@RequestParam long articleId,@RequestParam(defaultValue="") String q){return inventory.stocks(user(auth),payerId,articleId,q);}
|
||||
@GetMapping("/features") public Map<String,Object> features(Authentication auth) {
|
||||
var user=user(auth);user.require(7);String url=catalog.parameter(0,"URL_GEOCORDS").trim();
|
||||
if(!url.isEmpty())try {
|
||||
URI uri=URI.create(url);
|
||||
if(!Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null || uri.getFragment()!=null)throw new IllegalArgumentException();
|
||||
}catch(IllegalArgumentException e){throw invalid("Die Kartenadresse URL_GEOCORDS ist ungültig.");}
|
||||
return Map.of("mapUrl",url,"bookStock",user.hasRight(14));
|
||||
}
|
||||
@PostMapping("/map/locate") public MapPoint locate(Authentication auth,@Valid @RequestBody Station station) {
|
||||
user(auth).require(7);var point=routing.geocode(station);return new MapPoint(java.math.BigDecimal.valueOf(point.latitude()),java.math.BigDecimal.valueOf(point.longitude()));
|
||||
}
|
||||
@PostMapping("/map/address") public MapAddress address(Authentication auth,@Valid @RequestBody MapPoint point){user(auth).require(7);return routing.reverse(point);}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
|
||||
/** Existing emp_headquarters plus employeerights (one-based rights), within the original mandator. */
|
||||
@Service
|
||||
public class OrderEntryHeadquarters {
|
||||
private final DatabaseService db;
|
||||
public OrderEntryHeadquarters(DatabaseService db){this.db=db;}
|
||||
private record Access(Map<String,Object> employee,List<Map<String,Object>> branches,List<Map<String,Object>> rights) {}
|
||||
private Access access(LegacyUser user) {
|
||||
user.require(7);
|
||||
var rows=db.queryForList("SELECT u.hq_id,e.emp_rights,e.emp_headquarters,h.hq_admin FROM user u JOIN employee e ON e.usr_id=u.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_id=? AND e.emp_id=? AND u.usr_type=1 AND u.hq_id=?",user.userId(),user.employeeId(),user.originalHeadquartersId());
|
||||
if(rows.size()!=1)throw denied();
|
||||
var branches=db.queryForList("SELECT DISTINCT h.hq_id,h.hq_mnemonic FROM headquarters h JOIN mandatorheadquarters target ON target.hq_id=h.hq_id JOIN mandatorheadquarters origin ON origin.md_id=target.md_id WHERE origin.hq_id=? AND origin.md_id>0 ORDER BY h.hq_mnemonic,h.hq_id",user.originalHeadquartersId());
|
||||
return new Access(rows.getFirst(),branches,db.queryForList("SELECT hq_id,rgt_id FROM employeerights WHERE emp_id=?",user.employeeId()));
|
||||
}
|
||||
private String rights(LegacyUser user,long target,Access access) {
|
||||
String rights=text(access.employee(),"emp_rights");
|
||||
if(target==user.originalHeadquartersId())return rights;
|
||||
boolean root=id(access.employee(),"hq_admin")==user.employeeId();
|
||||
if(!root) {
|
||||
boolean listed=Arrays.stream(text(access.employee(),"emp_headquarters").split(",")).anyMatch(s -> s.trim().equals(Long.toString(target)));
|
||||
boolean entry=access.rights().stream().anyMatch(r -> id(r,"hq_id")==target && id(r,"rgt_id")==8);
|
||||
if(!listed || !entry)throw denied();
|
||||
}
|
||||
// PHP getRights: foreign-branch call-center access without master right 10 is restricted.
|
||||
if(rights.length()<=10 || rights.charAt(10)!='1') {
|
||||
String mask="1110100111000000000000000000000000000000000000000000000000000000000000";
|
||||
StringBuilder restricted=new StringBuilder();
|
||||
for(int i=0;i<rights.length();i++)restricted.append(i<mask.length() && mask.charAt(i)=='1' && rights.charAt(i)=='1'?'1':'0');
|
||||
rights=restricted.toString();
|
||||
}
|
||||
// Warehouse access is a separate master right; foreign headquarters require its matrix grant too.
|
||||
if(!root && access.rights().stream().noneMatch(r -> id(r,"hq_id")==target && id(r,"rgt_id")==15) && rights.length()>14)
|
||||
rights=rights.substring(0,14)+"0"+rights.substring(15);
|
||||
return rights;
|
||||
}
|
||||
public List<Option> available(LegacyUser user) {
|
||||
return db.readTransaction(() -> {
|
||||
var access=access(user);var result=new ArrayList<Option>();
|
||||
for(var branch:access.branches())try {
|
||||
long id=id(branch,"hq_id");String rights=rights(user,id,access);
|
||||
if(rights.length()>7 && rights.charAt(7)=='1')result.add(new Option(id,text(branch,"hq_mnemonic")));
|
||||
}catch(AccessDeniedException ignored) {}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
LegacyUser select(LegacyUser user,long target) {
|
||||
return db.readTransaction(() -> {
|
||||
var access=access(user);
|
||||
if(access.branches().stream().noneMatch(r -> id(r,"hq_id")==target))throw denied();
|
||||
var scoped=new LegacyUser(user.userId(),user.employeeId(),target,user.username(),rights(user,target,access),user.originalHeadquartersId());
|
||||
scoped.require(7);return scoped;
|
||||
});
|
||||
}
|
||||
private static AccessDeniedException denied(){return new AccessDeniedException("Keine Freigabe zur Auftragserfassung in dieser Niederlassung.");}
|
||||
}
|
||||
@RestController
|
||||
class OrderEntryHeadquartersController {
|
||||
private final OrderEntryHeadquarters headquarters;
|
||||
OrderEntryHeadquartersController(OrderEntryHeadquarters headquarters){this.headquarters=headquarters;}
|
||||
@GetMapping("/api/orders/entry/headquarters") Map<String,Object> available(Authentication auth) {
|
||||
var user=ApiController.user(auth);return Map.of("currentId",user.originalHeadquartersId(),"items",headquarters.available(user));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Articles and immediate stock bookings, matching inc_stock.inc.php without schema changes. */
|
||||
@Service
|
||||
public class OrderEntryInventory {
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final ThreadLocal<Boolean> currentReads=ThreadLocal.withInitial(() -> false);
|
||||
private List<Map<String,Object>> read(String sql,Object... parameters) {return currentReads.get()?db.lockRows(sql,parameters):db.queryForList(sql,parameters);}
|
||||
public OrderEntryInventory(DatabaseService db,OrderEntryCatalog catalog){this.db=db;this.catalog=catalog;}
|
||||
public List<ArticleOption> articles(LegacyUser user,String query) {
|
||||
user.require(7);
|
||||
return read("SELECT at_id,at_eid,at_name,at_serialno,at_totalweight,at_packingpieces FROM article WHERE hq_id=? AND at_visible=1 AND at_authenticated=1 AND (at_name LIKE ? OR at_eid LIKE ? OR at_barcode LIKE ?) ORDER BY at_name,at_id LIMIT 50",user.headquartersId(),search(query),search(query),search(query))
|
||||
.stream().map(r -> new ArticleOption(id(r,"at_id"),text(r,"at_eid"),text(r,"at_name"),id(r,"at_serialno")!=0,number(r,"at_totalweight"),(int)id(r,"at_packingpieces"))).toList();
|
||||
}
|
||||
public List<StockOption> stocks(LegacyUser user,long payerId,long articleId,String query) {
|
||||
user.require(7);user.require(14);
|
||||
return db.readTransaction(() -> {
|
||||
long customer=id(catalog.costCenter(user,payerId,true),"cs_id");article(user,articleId);
|
||||
var result=new ArrayList<StockOption>();
|
||||
var rows=read("SELECT s.*,COALESCE(sa.stkat_itemquantity,0) AS available FROM stock s LEFT JOIN stockarticle sa ON sa.stk_id=s.stk_id AND sa.at_id=? WHERE s.hq_id=? AND s.stk_visible=1 AND s.stk_readonly=0 AND (s.cs_id=0 OR s.cs_id=?) AND (s.stk_name LIKE ? OR s.stk_barcode LIKE ?) ORDER BY s.stk_name,s.stk_id LIMIT 200",articleId,user.headquartersId(),customer,search(query),search(query));
|
||||
for(var row:rows) {
|
||||
if(allowed(user,row,customer))result.add(new StockOption(id(row,"stk_id"),text(row,"stk_name")+" (#"+id(row,"stk_id")+")",id(row,"available")));
|
||||
if(result.size()==50)break;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
Map<String,Object> article(LegacyUser user,long articleId) {
|
||||
return one(read("SELECT * FROM article WHERE at_id=? AND hq_id=? AND at_visible=1 AND at_authenticated=1",articleId,user.headquartersId()),"Artikel ist nicht freigegeben oder gehört zu einer anderen Niederlassung.");
|
||||
}
|
||||
private Map<String,Object> article(LegacyUser user,ArticleItem item) {
|
||||
if(item.articleId()>0) {
|
||||
if(item.freeArticle()!=null)throw invalid("Bitte entweder einen Stammartikel oder eine freie Artikelposition wählen.");
|
||||
return article(user,item.articleId());
|
||||
}
|
||||
if(item.freeArticle()==null)throw invalid("Für eine freie Artikelposition fehlen Bezeichnung und Artikeldaten.");
|
||||
if(item.booking()!=null)throw invalid("Lagerbewegungen benötigen einen Artikel aus dem Artikelstamm.");
|
||||
var free=item.freeArticle();
|
||||
return fields("at_id",0,"at_name",free.name().trim(),"at_packingpieces",free.packingPieces());
|
||||
}
|
||||
private BigDecimal weight(ArticleItem item,Map<String,Object> article) {
|
||||
return item.freeArticle()!=null?item.freeArticle().totalWeight():number(article,"at_totalweight").multiply(BigDecimal.valueOf(item.quantity()));
|
||||
}
|
||||
private List<Map<String,Object>> ancestry(LegacyUser user,Map<String,Object> row) {
|
||||
var result=new ArrayList<Map<String,Object>>();var seen=new HashSet<Long>();
|
||||
while(true) {
|
||||
if(!seen.add(id(row,"stk_id")) || seen.size()>50)throw invalid("Die Lagerhierarchie ist ungültig.");
|
||||
result.add(row);long parent=id(row,"stk_pre_id");if(parent==0)return result;
|
||||
var parents=read("SELECT * FROM stock WHERE stk_id=? AND hq_id=?",parent,user.headquartersId());
|
||||
if(parents.size()!=1)throw invalid("Der übergeordnete Lagerort ist nicht zugänglich.");row=parents.getFirst();
|
||||
}
|
||||
}
|
||||
private boolean allowed(LegacyUser user,Map<String,Object> row,long customer) {
|
||||
var chain=ancestry(user,row);
|
||||
if(chain.stream().anyMatch(r -> id(r,"stk_visible")!=1 || id(r,"stk_readonly")!=0 || (id(r,"cs_id")!=0 && id(r,"cs_id")!=customer)))return false;
|
||||
var parameters=read("SELECT par_value FROM parameter WHERE emp_id=? AND hq_id IN (0,?) AND par_key='MASK_STK_SUBSTOCK_ACCESS' ORDER BY hq_id DESC",user.employeeId(),user.headquartersId());
|
||||
String restriction=parameters.isEmpty()?"":text(parameters.getFirst(),"par_value");
|
||||
if(restriction.isBlank())return true;
|
||||
long root=id(chain.getLast(),"stk_id");boolean restricted=false,accessible=false;
|
||||
for(String group:restriction.split("\\||--")) {
|
||||
String[] ids=group.split(",");if(ids.length==0 || !ids[0].trim().equals(Long.toString(root)))continue;
|
||||
restricted=true;
|
||||
for(int i=1;i<ids.length;i++) {String selected=ids[i].trim();if(chain.stream().anyMatch(r -> selected.equals(Long.toString(id(r,"stk_id")))))accessible=true;}
|
||||
}
|
||||
return !restricted || accessible;
|
||||
}
|
||||
private Map<String,Object> stock(LegacyUser user,long stockId,long customer) {
|
||||
var row=one(read("SELECT * FROM stock WHERE stk_id=? AND hq_id=?",stockId,user.headquartersId()),"Lagerort ist in dieser Niederlassung nicht verfügbar.");
|
||||
if(!allowed(user,row,customer))throw invalid("Für den Lagerort fehlen Schreibrechte oder die Kundenzuordnung.");return row;
|
||||
}
|
||||
record Key(long stock,long article) {}
|
||||
List<ArticleLine> quote(LegacyUser user,Request request) {return quote(user,request,false);}
|
||||
List<ArticleLine> quote(LegacyUser user,Request request,boolean locked) {
|
||||
currentReads.set(locked);
|
||||
try {return preview(user,request);}finally{currentReads.remove();}
|
||||
}
|
||||
private List<ArticleLine> preview(LegacyUser user,Request request) {
|
||||
if(request.articles().isEmpty())return List.of();
|
||||
user.require(7);
|
||||
long customer=id(catalog.costCenter(user,request.payerCostCenterId(),true),"cs_id");
|
||||
var balances=new HashMap<Key,Long>();var occupied=new HashMap<Long,Long>();var serialLocations=new HashMap<String,Long>();
|
||||
var result=new ArrayList<ArticleLine>();
|
||||
boolean booking=request.articles().stream().anyMatch(i -> i.booking()!=null);
|
||||
if(booking) {
|
||||
user.require(14);
|
||||
if(request.offer() || request.mode()==Mode.RECURRING || request.allOccurrences() || (request.tourName()!=null && !request.tourName().isBlank()))
|
||||
throw invalid("Sofortige Lagerbuchungen sind nicht mit Angeboten, Daueraufträgen oder Tourvorlagen kombinierbar.");
|
||||
}
|
||||
int copies=booking?request.copies():1;
|
||||
for(int copy=0;copy<copies;copy++)for(var item:request.articles()) {
|
||||
if(item.station()>request.stops().size())throw invalid("Die Artikelposition verweist auf eine nicht vorhandene Station.");
|
||||
var article=article(user,item);
|
||||
String serial=textOrEmpty(item.serialNumber()).trim();
|
||||
if(id(article,"at_serialno")!=0 && (serial.isBlank() || item.quantity()!=1))throw invalid("Seriennummernpflichtige Artikel benötigen eine Seriennummer und Menge 1.");
|
||||
if(!serial.isBlank() && item.quantity()!=1)throw invalid("Eine Seriennummer kann nur einer einzelnen Einheit zugeordnet werden.");
|
||||
String source="",destination="";
|
||||
if(item.booking()!=null) {
|
||||
long from=item.booking().fromStockId(),to=item.booking().toStockId();
|
||||
if(from==to)throw invalid("Quell- und Ziellager müssen verschieden sein; 0 steht für Ein-/Ausgang außerhalb des Lagers.");
|
||||
if(id(article,"at_stk_itemquantity")<=0 || id(article,"at_stk_areaquantity")<=0)throw invalid("Für diesen Artikel fehlt das Verhältnis von Stückzahl zu Lagerplätzen.");
|
||||
Map<String,Object> fromStock=from==0?Map.of():stock(user,from,customer),toStock=to==0?Map.of():stock(user,to,customer);
|
||||
source=from==0?"Wareneingang":text(fromStock,"stk_name");destination=to==0?"Warenausgang":text(toStock,"stk_name");
|
||||
long beforeFrom=from==0?0:balance(balances,from,item.articleId()),beforeTo=to==0?0:balance(balances,to,item.articleId());
|
||||
if(from>0 && beforeFrom<item.quantity())throw invalid("Nicht genügend Bestand in "+source+" für "+text(article,"at_name")+" (verfügbar: "+beforeFrom+").");
|
||||
if(!serial.isBlank()) {
|
||||
String key=item.articleId()+"|"+serial;
|
||||
long current=serialLocations.computeIfAbsent(key,k -> {
|
||||
var rows=read("SELECT stk_id FROM articleitem WHERE at_id=? AND ati_serialno=?",item.articleId(),serial);
|
||||
if(rows.size()>1)throw invalid("Die Seriennummer ist im Bestand mehrfach vorhanden.");
|
||||
return rows.isEmpty()?0L:id(rows.getFirst(),"stk_id");
|
||||
});
|
||||
if(current!=from)throw invalid("Die Seriennummer befindet sich nicht im angegebenen Quelllager oder ist bereits eingelagert.");
|
||||
serialLocations.put(key,to);
|
||||
}
|
||||
if(from>0)changeBalance(balances,occupied,fromStock,article,beforeFrom,beforeFrom-item.quantity());
|
||||
if(to>0) {
|
||||
long used=occupation(occupied,to),delta=areas(article,beforeTo+item.quantity())-areas(article,beforeTo);
|
||||
var chain=ancestry(user,toStock);
|
||||
if(id(chain.getLast(),"stk_planning")==1) {
|
||||
long reserved=read("SELECT COALESCE(SUM(stk_maxquantity),0) AS reserved FROM stock WHERE stk_pre_id=?",to).stream().mapToLong(r -> id(r,"reserved")).sum();
|
||||
if(used+delta+reserved>id(toStock,"stk_maxquantity"))throw invalid("Die Lagerkapazität in "+destination+" reicht für diese Buchung nicht aus.");
|
||||
}
|
||||
changeBalance(balances,occupied,toStock,article,beforeTo,beforeTo+item.quantity());
|
||||
}
|
||||
}
|
||||
if(weight(item,article).compareTo(new BigDecimal("999999.999"))>0)
|
||||
throw invalid("Das Artikelgesamtgewicht überschreitet die Grenze der Bestandsdatenbank.");
|
||||
if(copy==0)result.add(new ArticleLine(item.articleId(),item.station(),text(article,"at_name"),item.quantity(),serial,
|
||||
weight(item,article).setScale(3,RoundingMode.HALF_UP),(int)id(article,"at_packingpieces"),source,destination,item.booking()!=null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private long balance(Map<Key,Long> values,long stock,long article) {return values.computeIfAbsent(new Key(stock,article),k -> read("SELECT stkat_itemquantity FROM stockarticle WHERE stk_id=? AND at_id=?",stock,article).stream().mapToLong(r -> id(r,"stkat_itemquantity")).sum());}
|
||||
private long occupation(Map<Long,Long> values,long stock) {return values.computeIfAbsent(stock,k -> read("SELECT COALESCE(SUM(stkat_quantity),0) AS occupied FROM stockarticle WHERE stk_id=?",stock).stream().mapToLong(r -> id(r,"occupied")).sum());}
|
||||
private void changeBalance(Map<Key,Long> balances,Map<Long,Long> occupied,Map<String,Object> stock,Map<String,Object> article,long before,long after) {
|
||||
long id=id(stock,"stk_id");occupied.put(id,occupation(occupied,id)+areas(article,after)-areas(article,before));balances.put(new Key(id,id(article,"at_id")),after);
|
||||
}
|
||||
static long areas(Map<String,Object> article,long quantity) {
|
||||
return BigDecimal.valueOf(quantity).multiply(BigDecimal.valueOf(id(article,"at_stk_areaquantity"))).divide(BigDecimal.valueOf(id(article,"at_stk_itemquantity")),0,RoundingMode.CEILING).longValueExact();
|
||||
}
|
||||
void lock(LegacyUser user,Request request) {
|
||||
if(request.articles().isEmpty())return;
|
||||
boolean booking=request.articles().stream().anyMatch(i -> i.booking()!=null);
|
||||
List<String> tables=booking?List.of("article","tourarticle","stock","stockarticle","stockmove","articleitem"):List.of("article","tourarticle");
|
||||
for(String table:tables) {
|
||||
var engine=read("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?",table);
|
||||
if(engine.size()!=1 || !text(engine.getFirst(),"ENGINE").equalsIgnoreCase("InnoDB"))throw invalid("Artikel-/Lagerbuchung benötigt transaktionsfähige Bestandstabellen: "+table);
|
||||
}
|
||||
// Stable lock order includes parent stocks (capacity) and articles (new serial-number gaps).
|
||||
var stockIds=new TreeSet<Long>();var articleIds=new TreeSet<Long>();
|
||||
long customer=id(catalog.costCenter(user,request.payerCostCenterId(),true),"cs_id");
|
||||
for(var item:request.articles()) {
|
||||
if(item.articleId()>0)articleIds.add(item.articleId());
|
||||
if(item.booking()!=null)for(long id:List.of(item.booking().fromStockId(),item.booking().toStockId()))if(id>0)
|
||||
for(var parent:ancestry(user,stock(user,id,customer)))stockIds.add(id(parent,"stk_id"));
|
||||
}
|
||||
for(long id:stockIds)db.lockRows("SELECT stk_id FROM stock WHERE stk_id=? AND hq_id=?",id,user.headquartersId());
|
||||
for(long id:articleIds)db.lockRows("SELECT at_id FROM article WHERE at_id=? AND hq_id=?",id,user.headquartersId());
|
||||
for(long id:stockIds)db.lockRows("SELECT stk_id,at_id FROM stockarticle WHERE stk_id=? ORDER BY at_id",id);
|
||||
for(long id:articleIds)db.lockRows("SELECT ati_id FROM articleitem WHERE at_id=? ORDER BY ati_id",id);
|
||||
}
|
||||
void persist(LegacyUser user,Request request,long orderId,List<Long> tours,LocalDateTime now) {
|
||||
currentReads.set(true);
|
||||
try {write(user,request,orderId,tours,now);}finally{currentReads.remove();}
|
||||
}
|
||||
private void write(LegacyUser user,Request request,long orderId,List<Long> tours,LocalDateTime now) {
|
||||
if(request.articles().isEmpty())return;
|
||||
int sort=0;
|
||||
for(var item:request.articles()) {
|
||||
var article=article(user,item);String serial=textOrEmpty(item.serialNumber()).trim();
|
||||
db.insert("tourarticle",fields("jb_id",orderId,"tr_id",tours.get(item.station()-1),"tr_sort",item.station(),"trat_sort",++sort,"at_id",item.articleId(),
|
||||
"trat_name",text(article,"at_name").substring(0,Math.min(30,text(article,"at_name").length())),"trat_quantity",item.quantity(),
|
||||
"trat_weight",weight(item,article).setScale(3,RoundingMode.HALF_UP),"trat_packingpieces",id(article,"at_packingpieces"),
|
||||
"trat_int_serialno","","trat_serialno",serial,"trat_price",item.freeArticle()==null?BigDecimal.ZERO:item.freeArticle().price(),"trat_state",0,"trat_remark",textOrEmpty(item.remark()),"trat_createtime",now));
|
||||
if(item.booking()==null)continue;
|
||||
book(user,item,orderId,now);
|
||||
}
|
||||
}
|
||||
long book(LegacyUser user,ArticleItem item,long orderId,LocalDateTime now) {
|
||||
boolean previous=currentReads.get();currentReads.set(true);
|
||||
try {
|
||||
var article=article(user,item);String serial=textOrEmpty(item.serialNumber()).trim();
|
||||
long from=item.booking().fromStockId(),to=item.booking().toStockId();
|
||||
for(long stock:List.of(from,to))if(stock>0) {
|
||||
long current=balance(new HashMap<>(),stock,item.articleId());long after=current+(stock==from?-item.quantity():item.quantity());
|
||||
if(after<0)throw conflict("Der Lagerbestand hat sich geändert. Bitte neu berechnen.");
|
||||
if(after==0)db.update("DELETE FROM stockarticle WHERE stk_id=? AND at_id=?",stock,item.articleId());
|
||||
else if(db.update("UPDATE stockarticle SET stkat_itemquantity=?,stkat_quantity=?,stkat_modifytime=? WHERE stk_id=? AND at_id=?",after,areas(article,after),now,stock,item.articleId())==0)
|
||||
db.update("INSERT INTO stockarticle (stk_id,at_id,stkat_itemquantity,stkat_quantity,stkat_modifytime) VALUES (?,?,?,?,?)",stock,item.articleId(),after,areas(article,after),now);
|
||||
}
|
||||
var serialData=new LinkedHashMap<String,Object>();
|
||||
if(!serial.isBlank()&&from>0&&to==0){
|
||||
var rows=read("SELECT * FROM articleitem WHERE at_id=? AND stk_id=? AND ati_serialno=?",item.articleId(),from,serial);
|
||||
if(rows.size()!=1)throw conflict("Der Seriennummernbestand hat sich geändert.");
|
||||
rows.getFirst().forEach((key,value)->{if(key.startsWith("ati_data_"))serialData.put(key,value);});
|
||||
}
|
||||
if(!serial.isBlank()) {
|
||||
if(from==0)db.insert("articleitem",fields("at_id",item.articleId(),"stk_id",to,"ati_serialno",serial));
|
||||
else if(to==0)db.update("DELETE FROM articleitem WHERE at_id=? AND stk_id=? AND ati_serialno=?",item.articleId(),from,serial);
|
||||
else db.update("UPDATE articleitem SET stk_id=? WHERE at_id=? AND stk_id=? AND ati_serialno=?",to,item.articleId(),from,serial);
|
||||
}
|
||||
long movement=db.insert("stockmove",fields("stkmv_timestamp",now,"stk_id_from",from,"stk_id_to",to,"at_id",item.articleId(),"stkmv_itemquantity",item.quantity(),
|
||||
"stkmv_bundlequantity",id(article,"at_bundlequantity"),"stkmv_bundlecode",text(article,"at_bundlecode"),"stkmv_tan",Long.toString(orderId),
|
||||
"stkmv_remark",textOrEmpty(item.remark()),"stkmv_serialno",serial,"stkmv_data","","at_id_bundle",0,"stkmv_datetime",now,"stkmv_datetime2",now,"jb_id",orderId,"usr_id",user.userId()));
|
||||
if(!serialData.isEmpty()){
|
||||
String data;try{data=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(serialData);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","ngstkmeta","gdc_obj_id",movement,"gdc_gen_fieldname","articleitem","gdc_content",data,"gdc_context",""));
|
||||
}
|
||||
return movement;
|
||||
} finally {if(previous)currentReads.set(true);else currentReads.remove();}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Commands for new orders only; no client-owned status, totals, branch or audit fields. */
|
||||
public final class OrderEntryModels {
|
||||
private OrderEntryModels() {}
|
||||
public enum Mode { IMMEDIATE, RESERVATION, RECURRING, RETROSPECTIVE }
|
||||
public enum Payment { INVOICE, CASH }
|
||||
public enum PriceMode { TARIFF, FIXED, KILOMETER, ZONE, SERVICES }
|
||||
public enum LonghaulMode { AUTO, ASAP }
|
||||
public record Station(
|
||||
@Positive Long costCenterId,
|
||||
@NotNull Models.StopType type,
|
||||
@NotBlank @Size(max=50) String name,
|
||||
@NotBlank @Size(max=50) String street,
|
||||
@NotBlank @Size(max=10) String houseNumber,
|
||||
@NotBlank @Size(max=10) String postalCode,
|
||||
@NotBlank @Size(max=50) String city,
|
||||
@NotBlank @Pattern(regexp="[A-Z]{2}") String country,
|
||||
@Size(max=50) String contact, @Size(max=20) String phone,
|
||||
@Size(max=2000) String note, @Size(max=30) String reference, @Min(0) Integer photoWorkflow,
|
||||
@Positive Long billingPostalId, @Size(max=30) List<@Valid Attachment> attachments) {
|
||||
public Station { attachments=attachments==null?List.of():List.copyOf(attachments); }
|
||||
}
|
||||
public record Attachment(@NotBlank @Size(max=255) String filename, @NotBlank @Size(max=1500) String viewUrl,
|
||||
@Size(max=200) String remark) {}
|
||||
public record ServiceItem(@Positive long serviceId, @Positive long typeId,
|
||||
@NotNull @DecimalMin("0.001") @DecimalMax("99999") @Digits(integer=5,fraction=3) BigDecimal quantity) {}
|
||||
public record Insurance(@NotNull @DecimalMin("0.01") @DecimalMax("9999999.99") @Digits(integer=7,fraction=2) BigDecimal goodsValue,
|
||||
@Min(1) @Max(5) int rate, boolean specialCase) {}
|
||||
public record Recurrence(@Min(1) @Max(11) int rhythm,
|
||||
@NotNull @Pattern(regexp="[01]{7}") String weekdays,
|
||||
LocalDate endDate) {}
|
||||
public record Pricing(@NotNull PriceMode mode,
|
||||
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal fixedPrice,
|
||||
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal servicePrice,
|
||||
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal courierPrice,
|
||||
@DecimalMin("0.00") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal courierServicePrice,
|
||||
@DecimalMin("-100") @DecimalMax("100") @Digits(integer=3,fraction=2) BigDecimal markup,
|
||||
@DecimalMin("-100") @DecimalMax("100") @Digits(integer=3,fraction=2) BigDecimal courierMarkup,
|
||||
boolean applyFixedDiscount,
|
||||
@NotNull @Positive Long taxId) {}
|
||||
public record Request(
|
||||
@NotNull Mode mode, LocalDateTime pickupAt, LocalDateTime warningAt,
|
||||
@Valid Recurrence recurrence,
|
||||
@NotNull @Positive Long payerCostCenterId,
|
||||
@NotNull Payment payment, @Min(1) @Max(100) Integer cashStop,
|
||||
@NotNull @Min(0) Integer vehicleTypeId,
|
||||
@Positive Long courierVehicleId,
|
||||
@NotNull @Size(min=1,max=100) List<@Valid @NotNull Station> stops,
|
||||
@NotBlank @Size(max=50) String orderer, @Size(max=50) String reference,
|
||||
@Size(max=4000) String notes, @Size(max=120) String dispatchNote,
|
||||
@DecimalMin("0") @DecimalMax("999999") @Digits(integer=6,fraction=0) BigDecimal weight,
|
||||
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal length,
|
||||
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal width,
|
||||
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal height,
|
||||
@DecimalMin("0") @DecimalMax("9999") @Digits(integer=4,fraction=0) BigDecimal positions,
|
||||
@DecimalMin("0") @DecimalMax("999999") BigDecimal distanceKm,
|
||||
boolean manualDispatch, boolean incomplete, boolean confirmCourierLimit, LonghaulMode longhaul,
|
||||
@NotNull @Valid Pricing pricing,
|
||||
@Size(max=64) String quoteFingerprint,
|
||||
@NotNull @Pattern(regexp="[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}") String requestId,
|
||||
@Min(1) @Max(6) int origin, @Size(max=40) String originOther, boolean offer,
|
||||
@DecimalMin("0") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal toll,
|
||||
@Size(max=4000) String invoiceOnlyText,
|
||||
@Size(max=50) List<@NotBlank @Size(max=20) String> courierFilters,
|
||||
boolean cancelIfUnavailable, boolean allCouriers, LocalDateTime acceptUntil,
|
||||
boolean acceptUnverifiedAddresses,
|
||||
@Min(1) @Max(10) int copies, boolean allOccurrences,
|
||||
@Size(max=50) String tourName, @Positive Long overwriteTemplateId, @Positive Long sourceOrderId,
|
||||
@Valid Insurance insurance, @Size(max=100) List<@Valid @NotNull ServiceItem> services,
|
||||
@Email @Size(max=254) String invoiceEmail, boolean updateInvoiceEmail,
|
||||
boolean costSplit, @Size(max=20) List<@NotNull @Valid CourierShare> courierShares,
|
||||
@Size(max=100) List<@NotNull @Valid ArticleItem> articles, @Valid SustainabilityInput sustainability, @Positive Integer photoSet) {
|
||||
public Request {
|
||||
courierShares=courierShares==null?List.of():List.copyOf(courierShares);
|
||||
articles=articles==null?List.of():List.copyOf(articles);
|
||||
if(origin==0)origin=1;
|
||||
if(copies==0)copies=1;
|
||||
courierFilters=courierFilters==null?List.of():List.copyOf(courierFilters);
|
||||
services=services==null?List.of():List.copyOf(services);
|
||||
}
|
||||
}
|
||||
public record CourierShare(@Positive long vehicleId,
|
||||
@NotNull @DecimalMin("0.01") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal amount) {}
|
||||
public record ArticleItem(@Min(0) long articleId, @Min(1) @Max(100) int station,
|
||||
@Min(1) @Max(32767) int quantity, @Size(max=50) String serialNumber,
|
||||
@Size(max=255) String remark, @Valid StockBooking booking, @Valid FreeArticle freeArticle) {
|
||||
public ArticleItem(long articleId,int station,int quantity,String serialNumber,String remark,StockBooking booking) {this(articleId,station,quantity,serialNumber,remark,booking,null);}
|
||||
}
|
||||
public record FreeArticle(@NotBlank @Size(max=30) String name,
|
||||
@NotNull @DecimalMin("0") @DecimalMax("999999.999") @Digits(integer=6,fraction=3) BigDecimal totalWeight,
|
||||
@Min(0) @Max(127) int packingPieces,
|
||||
@NotNull @DecimalMin("0") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal price) {}
|
||||
public record StockBooking(@Min(0) long fromStockId, @Min(0) long toStockId) {}
|
||||
public record ArticleOption(long id,String number,String name,boolean serialRequired,BigDecimal weight,int packingPieces) {}
|
||||
public record StockOption(long id,String label,long available) {}
|
||||
public record ArticleLine(long articleId,int station,String name,int quantity,String serialNumber,
|
||||
BigDecimal weight,int packingPieces,String source,String destination,boolean booked) {}
|
||||
public record CostAllocation(long costCenterId,String name,BigDecimal net,BigDecimal toll,BigDecimal tax,BigDecimal gross) {}
|
||||
public record CourierAllocation(long vehicleId,long courierId,String sid,BigDecimal base,BigDecimal subtotal,
|
||||
BigDecimal net,BigDecimal service,BigDecimal discount,BigDecimal taxRate,String taxSign,long payerId) {}
|
||||
public enum SustainabilityMode { AUTO, MANUAL }
|
||||
public record SustainabilityInput(@NotNull SustainabilityMode mode,
|
||||
@DecimalMin("0.01") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal distanceKm,
|
||||
@DecimalMin("0.01") @DecimalMax("999999.99") @Digits(integer=6,fraction=2) BigDecimal co2eKg) {}
|
||||
public record SustainabilityResult(BigDecimal distanceKm,BigDecimal co2eKg,String source,String calculation,String inputFingerprint) {}
|
||||
public record MapPoint(@DecimalMin("-90") @DecimalMax("90") @NotNull BigDecimal latitude,
|
||||
@DecimalMin("-180") @DecimalMax("180") @NotNull BigDecimal longitude) {}
|
||||
public record MapAddress(String street,String houseNumber,String postalCode,String city,String country,String warning) {}
|
||||
public record Option(long id, String label) {}
|
||||
public record Tax(long id, String label, BigDecimal rate) {}
|
||||
public record CostCenter(long id, long customerId, String customerNumber, String customerName,
|
||||
String name, Long defaultPayerId, BigDecimal fixedDiscount, Long taxId) {}
|
||||
public record CustomerDefaults(String invoiceEmail, String vatId, boolean referenceRequired, boolean calculationRequired) {}
|
||||
public record CourierFilter(String code, String label, boolean mandatory) {}
|
||||
public record ServiceOption(long serviceId,long typeId,String service,String type) {}
|
||||
public record SavedOrder(long id,String name,String customer,LocalDateTime pickupAt) {}
|
||||
public record Address(long id, long costCenterId, String name, String street, String houseNumber,
|
||||
String postalCode, String city, String country, String contact, String phone, String note) {}
|
||||
public record Vehicle(long id, long courierId, String sid, String name, int vehicleTypeId, String plate) {}
|
||||
public record Options(List<Option> vehicleTypes, List<Tax> taxes, List<Option> rhythms,
|
||||
boolean editCourierPrice, boolean overrideCourierLimit, List<String> euCountries) {}
|
||||
public record Component(int sequence, String name, String type, BigDecimal price,
|
||||
BigDecimal discount, BigDecimal net, BigDecimal subtotal,
|
||||
BigDecimal courierNet, BigDecimal courierSubtotal) {}
|
||||
public record Quote(BigDecimal net, BigDecimal tax, BigDecimal gross, BigDecimal subtotal,
|
||||
BigDecimal courierNet, BigDecimal courierSubtotal, BigDecimal markup,
|
||||
BigDecimal courierMarkup, BigDecimal discount, BigDecimal taxRate,
|
||||
String taxSign, BigDecimal courierBase, BigDecimal courierService,
|
||||
BigDecimal courierDeduction, int invoiceMode, boolean batch,
|
||||
List<Component> components, String fingerprint,
|
||||
BigDecimal courierAgio, BigDecimal courierLimit, boolean courierLimitExceeded,
|
||||
RouteInfo route, List<PhotoChoice> photos, List<CalculationLine> calculation, BigDecimal calculatedBase, BigDecimal calculatedCourierBase, BigDecimal toll, BigDecimal insurancePremium, int copies, List<ScheduleItem> schedule, List<CostAllocation> costAllocations, List<CourierAllocation> courierAllocations, List<ArticleLine> articleLines, SustainabilityResult sustainability, PhotoSet photoSet) {}
|
||||
public record ScheduleItem(LocalDateTime pickupAt,int count,BigDecimal net,BigDecimal gross,BigDecimal courierNet,String priceFingerprint) {}
|
||||
public record PhotoWorkflow(int number, String name, boolean defaultWorkflow, int min, int max, String stationScope, List<String> positions) {}
|
||||
public record PhotoSet(int number, String name, boolean defaultSet, List<PhotoWorkflow> workflows) {}
|
||||
public record PhotoOptions(boolean configured, List<PhotoSet> sets) {}
|
||||
public record PhotoPlan(PhotoSet set, List<PhotoChoice> choices) {}
|
||||
public record PhotoChoice(int station, int number, String name) {}
|
||||
public record HeadquartersRoute(long id, String name, BigDecimal roundTripKm) {}
|
||||
public record RouteInfo(BigDecimal distanceKm, BigDecimal calculationKm, long durationSeconds,
|
||||
long dispatchHeadquartersId, String dispatchHeadquarters, long pricingHeadquartersId,
|
||||
boolean longhaulEligible, boolean longhaul, List<HeadquartersRoute> comparedHeadquarters) {}
|
||||
public record CalculationLine(String name, BigDecimal quantity, BigDecimal rate, BigDecimal amount) {}
|
||||
public record Created(long id, String number, int legacyStatus, boolean replayed, LocalDateTime pickupAt, boolean photoSyncPending, List<Long> orderIds) {
|
||||
public Created(long id,String number,int status,boolean replayed,LocalDateTime at,boolean pending) {this(id,number,status,replayed,at,pending,List.of(id));}
|
||||
public Created {orderIds=orderIds==null?List.of(id):List.copyOf(orderIds);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import jakarta.servlet.*;
|
||||
import jakarta.servlet.http.*;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Per-request entry scope; never changes the session principal or other browser tabs. */
|
||||
final class OrderEntryScopeFilter extends OncePerRequestFilter {
|
||||
private final OrderEntryHeadquarters headquarters;private final ObjectMapper json;
|
||||
OrderEntryScopeFilter(OrderEntryHeadquarters headquarters,ObjectMapper json){this.headquarters=headquarters;this.json=json;}
|
||||
@Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain) throws ServletException,IOException {
|
||||
String selected=request.getHeader("X-Order-Headquarters"),path=request.getServletPath();
|
||||
boolean entry=path.startsWith("/api/orders/entry/") || path.equals("/api/orders/quote") || path.equals("/api/orders") && request.getMethod().equals("POST");
|
||||
if(selected==null || !entry || path.equals("/api/orders/entry/headquarters")){chain.doFilter(request,response);return;}
|
||||
if(!selected.matches("[1-9][0-9]{0,9}"))throw new AccessDeniedException("Ungültige Erfassungsniederlassung.");
|
||||
var original=SecurityContextHolder.getContext();
|
||||
var auth=original.getAuthentication();
|
||||
try {
|
||||
var user=headquarters.select(ApiController.user(auth),Long.parseLong(selected));
|
||||
var scoped=SecurityContextHolder.createEmptyContext();
|
||||
scoped.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(user,null,auth.getAuthorities()));
|
||||
SecurityContextHolder.setContext(scoped);chain.doFilter(request,response);
|
||||
}catch(org.springframework.dao.DataAccessException e){
|
||||
if(response.isCommitted())throw e;
|
||||
response.setStatus(503);response.setContentType("application/json;charset=UTF-8");json.writeValue(response.getOutputStream(),ApiErrors.body(ApiErrors.DATABASE_UNAVAILABLE));
|
||||
}finally{SecurityContextHolder.setContext(original);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
import static de.stadtbote.votian.LegacyMapping.id;
|
||||
|
||||
/** getNextOrderDate / isPublicHoliday: holidays are read from the maintained HQ calendar. */
|
||||
final class OrderEntrySeries {
|
||||
static List<LocalDateTime> dates(DatabaseService db,LegacyUser user,Request request) {
|
||||
if(!request.allOccurrences())return Collections.singletonList(request.pickupAt());
|
||||
if(request.mode()!=Mode.RECURRING || request.recurrence()==null || request.recurrence().endDate()==null)
|
||||
throw invalid("Für die sofortige Serienanlage sind Dauerauftrag und Serienende erforderlich.");
|
||||
var start=request.pickupAt();var end=request.recurrence().endDate();
|
||||
if(end.isAfter(start.toLocalDate().plusYears(2)))throw invalid("Die sofortige Serienanlage ist auf zwei Jahre begrenzt.");
|
||||
var holidays=new HashSet<LocalDate>();
|
||||
for(var row:db.queryForList("SELECT ph_year,ph_month,ph_day FROM publicholiday WHERE hq_id=? AND ph_year BETWEEN ? AND ?",user.headquartersId(),start.getYear(),end.getYear()))
|
||||
holidays.add(LocalDate.of((int)id(row,"ph_year"),(int)id(row,"ph_month"),(int)id(row,"ph_day")));
|
||||
var dates=new ArrayList<LocalDateTime>();dates.add(start);
|
||||
var date=start.toLocalDate();
|
||||
while(true) {
|
||||
date=next(date,request.recurrence(),holidays,end);
|
||||
if(date.isAfter(end))break;
|
||||
var at=date.atTime(start.toLocalTime());
|
||||
if(ZoneId.of("Europe/Berlin").getRules().getValidOffsets(at).size()!=1)throw invalid("Ein Serientermin fällt in eine ungültige oder mehrdeutige Uhrzeit bei der Zeitumstellung: "+at);
|
||||
dates.add(at);
|
||||
if(dates.size()*request.copies()>500 || dates.size()*request.copies()*request.stops().size()>10000)
|
||||
throw invalid("Die sofortige Serie ist zu groß (maximal 500 Aufträge bzw. 10.000 Stationen). Bitte das Serienende eingrenzen.");
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
static LocalDate next(LocalDate previous,Recurrence recurrence,Set<LocalDate> holidays,LocalDate end) {
|
||||
int rhythm=recurrence.rhythm();LocalDate next;
|
||||
if(rhythm<=3 || rhythm==9) {
|
||||
for(next=previous.plusDays(1);!next.isAfter(end);next=next.plusDays(1)) {
|
||||
boolean holiday=holidays.contains(next),weekday=recurrence.weekdays().charAt(next.getDayOfWeek().getValue()-1)=='1';
|
||||
if((rhythm==9 && holiday) || (!holiday && weekday))return next;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
boolean backwards=rhythm==7 || rhythm==8;
|
||||
next=switch(rhythm) {
|
||||
case 4->previous.plusWeeks(1);case 5->previous.plusWeeks(2);case 11->previous.plusWeeks(4);
|
||||
case 6->previous.plusMonths(1);
|
||||
case 7->previous.plusMonths(1).withDayOfMonth(previous.plusMonths(1).lengthOfMonth());
|
||||
case 8->previous.getDayOfMonth()<20?previous.withDayOfMonth(previous.lengthOfMonth()):previous.plusMonths(1).withDayOfMonth(15);
|
||||
case 10->previous.getDayOfMonth()<16?previous.withDayOfMonth(16):previous.plusMonths(1).withDayOfMonth(1);
|
||||
default->throw invalid("Ungültiger Serienrhythmus.");
|
||||
};
|
||||
while(holidays.contains(next))next=next.plusDays(backwards?-1:1);
|
||||
if(!next.isAfter(previous))throw invalid("Der Feiertagskalender lässt keinen folgenden Serientermin zu.");
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import jakarta.validation.Validator;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.LegacyOrderPricing.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
|
||||
@Service
|
||||
public class OrderEntryService {
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final LegacyOrderPricing pricing;
|
||||
private final Validator validator;
|
||||
private final OrderEntryAudit audit;
|
||||
private final OrderRoutingService routing;
|
||||
private final OrderPhotoWorkflows photos;
|
||||
private final String logSchema;
|
||||
private final Clock clock;
|
||||
private final OrderEntryExtras extras;
|
||||
private final OrderEntryAccounting accounting;
|
||||
private final OrderEntryInventory inventory;
|
||||
private OrderSustainability sustainability;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public OrderEntryService(DatabaseService db,OrderEntryCatalog catalog,LegacyOrderPricing pricing,Validator validator,
|
||||
OrderEntryAudit audit,OrderRoutingService routing,OrderPhotoWorkflows photos,OrderSustainability sustainability,@Value("${app.history-schema:phoenix_log}") String schema) {
|
||||
this(db,catalog,pricing,validator,audit,routing,photos,schema,Clock.system(ZoneId.of("Europe/Berlin")));
|
||||
this.sustainability=sustainability;
|
||||
}
|
||||
OrderEntryService(DatabaseService db,OrderEntryCatalog catalog,LegacyOrderPricing pricing,Validator validator,
|
||||
OrderEntryAudit audit,OrderRoutingService routing,OrderPhotoWorkflows photos,String schema,Clock clock) {
|
||||
this.db=db;this.catalog=catalog;this.pricing=pricing;this.validator=validator;this.audit=audit;this.clock=clock;this.routing=routing;this.photos=photos;
|
||||
if(!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");
|
||||
this.logSchema=schema;this.extras=new OrderEntryExtras(db,catalog);this.accounting=new OrderEntryAccounting(db,catalog);this.inventory=new OrderEntryInventory(db,catalog);
|
||||
}
|
||||
public Quote quote(LegacyUser user,Request request) {
|
||||
validate(user,request);
|
||||
var prepared=db.readTransaction(() -> prepare(user,request));
|
||||
validateAddresses(request);
|
||||
var selections=photos.resolve(request,prepared.photoCustomer());
|
||||
var route=routing.resolve(user,request,prepared.dispatchHq());
|
||||
var emission=resolveSustainability(user,request,prepared,route);
|
||||
return db.readTransaction(() -> batch(user,request,prepared,route,selections,false,emission).summary());
|
||||
}
|
||||
public Created create(LegacyUser user,Request request) {
|
||||
validate(user,request);
|
||||
String requestHash=requestHash(request);
|
||||
// A committed retry remains available even when routing or photography is down.
|
||||
Created created=db.readTransaction(() -> replay(user,request,requestHash));
|
||||
if(created==null) {
|
||||
var preview=db.readTransaction(() -> prepare(user,request));
|
||||
validateAddresses(request);
|
||||
var selections=photos.resolve(request,preview.photoCustomer());
|
||||
var route=routing.resolve(user,request,preview.dispatchHq());
|
||||
var emission=resolveSustainability(user,request,preview,route);
|
||||
created=db.writeTransaction(() -> {
|
||||
requireTransactionalTables();
|
||||
if(db.lockRows("SELECT usr_id FROM user WHERE usr_id=? AND hq_id=?",user.userId(),user.originalHeadquartersId()).isEmpty())
|
||||
throw invalid("Das Benutzerkonto ist nicht mehr verfügbar. Bitte erneut anmelden.");
|
||||
Created previous=replay(user,request,requestHash);if(previous!=null)return previous;
|
||||
var prepared=prepare(user,request);
|
||||
if(id(prepared.payer(),"cs_id")!=id(preview.payer(),"cs_id") || prepared.photoCustomer()!=preview.photoCustomer())throw conflict("Der Zahler hat sich geändert. Bitte die Vorschau neu laden.");
|
||||
inventory.lock(user,request);
|
||||
Batch batch=batch(user,request,prepared,route,selections,true,emission);
|
||||
if(request.quoteFingerprint()==null || !batch.summary().fingerprint().equals(request.quoteFingerprint()))
|
||||
throw conflict("Die Preisvorschau fehlt oder hat sich geändert. Bitte den Preis erneut berechnen und prüfen.");
|
||||
for(var item:batch.items())checkCourierLimit(user,request,item.quote());
|
||||
Created first=null;var ids=new ArrayList<Long>();long[] previousIds=new long[request.copies()];
|
||||
for(int n=0;n<batch.items().size();n++) {
|
||||
var item=batch.items().get(n);
|
||||
for(int i=0;i<request.copies();i++) {
|
||||
int index=n*request.copies()+i;
|
||||
Request command=index==0?request:request.allOccurrences()?extras.seriesCommand(request,index,item.prepared().at(),(n==0 || catalog.enabled(user.headquartersId(),"MASK_STANDING_KEEP_COMMISSION"))):extras.copyCommand(request,index);
|
||||
Created createdItem=persist(user,command,item.prepared(),item.quote(),index==0?requestHash:requestHash(command));
|
||||
if(first==null)first=createdItem;ids.add(createdItem.id());
|
||||
if(request.allOccurrences() && n>0) {
|
||||
db.update("UPDATE job SET jb_id_copy=?,jb_id_copy_permanent=? WHERE jb_id=? AND hq_id=?",createdItem.id(),createdItem.id(),previousIds[i],user.headquartersId());
|
||||
db.update("UPDATE job SET jb_origin=5 WHERE jb_id=? AND hq_id=?",createdItem.id(),user.headquartersId());
|
||||
}
|
||||
previousIds[i]=createdItem.id();
|
||||
}
|
||||
}
|
||||
if(ids.size()>1)extras.saveGroup(first.id(),ids);
|
||||
return new Created(first.id(),first.number(),first.legacyStatus(),false,first.pickupAt(),first.photoSyncPending(),ids);
|
||||
});
|
||||
}
|
||||
boolean pending=false;
|
||||
for(long orderId:created.orderIds()) {
|
||||
audit.tryPublish(orderId);photos.tryPublish(orderId);
|
||||
for(long child:accounting.childIds(orderId))audit.tryPublish(child);
|
||||
long insuranceId=extras.linkedInsurance(orderId);if(insuranceId>0)audit.tryPublish(insuranceId);
|
||||
pending|=photos.pending(orderId);
|
||||
}
|
||||
// Include the actual status after photo publication, without changing idempotency semantics.
|
||||
var rows=db.queryForList("SELECT jb_status FROM job WHERE jb_id=? AND hq_id=?",created.id(),user.headquartersId());
|
||||
int status=rows.isEmpty()?created.legacyStatus():(int)id(rows.getFirst(),"jb_status");
|
||||
return new Created(created.id(),created.number(),status,created.replayed(),created.pickupAt(),pending,created.orderIds());
|
||||
}
|
||||
static String requestHash(Request request){
|
||||
String text=request.toString();
|
||||
// Preserve receipts from the previous request record when the added optional field is absent.
|
||||
if(request.photoSet()==null)text=text.substring(0,text.length()-", photoSet=null]".length())+"]";
|
||||
return hash(text);
|
||||
}
|
||||
private Created replay(LegacyUser user,Request request,String requestHash) {
|
||||
var previous=db.queryForList("SELECT sp_context FROM `"+logSchema+"`.semaphor WHERE sp_obj_type='usr' AND sp_obj_id=? AND sp_fieldname='ng_order_request' AND sp_content=?",user.userId(),request.requestId());
|
||||
if(previous.isEmpty())return null;
|
||||
String[] saved=text(previous.getFirst(),"sp_context").split("\\|");
|
||||
if(saved.length!=3 || !saved[0].equals(Long.toString(user.headquartersId())) || !saved[1].equals(requestHash))
|
||||
throw conflict("Diese Anfragekennung wurde bereits für andere Auftragsdaten verwendet.");
|
||||
long id=Long.parseLong(saved[2]);
|
||||
var order=one(db.queryForList("SELECT jb_status,jb_ordertime FROM job WHERE jb_id=? AND hq_id=?",id,user.headquartersId()),"Der bereits angelegte Auftrag ist nicht mehr verfügbar.");
|
||||
return new Created(id,Long.toString(id),(int)id(order,"jb_status"),true,dateTime(order,"jb_ordertime"),false,extras.group(id));
|
||||
}
|
||||
|
||||
record Prepared(Map<String,Object> payer,Map<String,Object> headquarters,Map<String,Object> vehicle,
|
||||
LocalDateTime at,LocalDateTime now,long cashPayer,long dispatchHq,boolean favoured,long photoCustomer) {}
|
||||
private Prepared prepare(LegacyUser user,Request request) {
|
||||
var payer=catalog.costCenter(user,request.payerCostCenterId(),request.payment()==Payment.INVOICE);
|
||||
long photoCustomer=id(payer,"cs_id");
|
||||
var hq=catalog.headquarters(user);
|
||||
for(Station stop:request.stops()) if(stop.costCenterId()!=null)catalog.costCenter(user,stop.costCenterId(),false);
|
||||
var vehicle=request.courierVehicleId()==null?Map.<String,Object>of():catalog.vehicle(user,request.courierVehicleId());
|
||||
LocalDateTime now=LocalDateTime.now(clock).withNano(0),at=request.mode()==Mode.IMMEDIATE?now:request.pickupAt();
|
||||
if(request.mode()==Mode.RETROSPECTIVE && at.isAfter(now))throw invalid("Eine Nacherfassung muss in der Vergangenheit liegen.");
|
||||
if((request.mode()==Mode.RESERVATION || request.mode()==Mode.RECURRING) && at.isBefore(now))
|
||||
throw invalid("Der Vorbestellungs- oder Serienbeginn muss in der Zukunft liegen.");
|
||||
long cashPayer=0;
|
||||
if(request.payment()==Payment.CASH) {
|
||||
String configured=catalog.parameter(user.headquartersId(),"CSC_ID_PAYER_CASH");
|
||||
if(!configured.matches("[0-9]+")) throw invalid("Für die Niederlassung ist kein Systembarzahler konfiguriert.");
|
||||
cashPayer=Long.parseLong(configured);
|
||||
catalog.costCenter(user,cashPayer,false);
|
||||
if(!catalog.enabled(user.headquartersId(),"MASK_CASH_PAYER_SELECT"))
|
||||
payer=catalog.costCenter(user,cashPayer,false);
|
||||
}
|
||||
var dispatch=db.queryForList("SELECT hq_id FROM serviceplz WHERE srvp_plz=?",request.stops().getFirst().postalCode());
|
||||
long dispatchHq=dispatch.isEmpty()?user.headquartersId():id(dispatch.getFirst(),"hq_id");
|
||||
if(dispatchHq<=0)dispatchHq=user.headquartersId();
|
||||
if(!request.stops().getFirst().country().equals("DE"))dispatchHq=user.headquartersId();
|
||||
boolean favoured=!db.queryForList("SELECT cs_id FROM customercourier WHERE cs_id=? AND cscr_relation=1 LIMIT 1",id(payer,"cs_id")).isEmpty();
|
||||
extras.validate(user,request,payer);
|
||||
if(request.acceptUntil()!=null && request.acceptUntil().isBefore(at))throw invalid("Die Annahmefrist liegt vor dem Auftragsbeginn.");
|
||||
return new Prepared(payer,hq,vehicle,at,now,cashPayer,dispatchHq,favoured,photoCustomer);
|
||||
}
|
||||
|
||||
void validate(LegacyUser user,Request request) {
|
||||
user.require(7);
|
||||
if(request==null)throw invalid("Auftragsdaten fehlen.");
|
||||
var violations=validator.validate(request);
|
||||
if(!violations.isEmpty())throw invalid("Bitte die Auftragsdaten prüfen: "+violations.stream()
|
||||
.sorted(Comparator.comparing(v -> v.getPropertyPath().toString()))
|
||||
.map(v -> v.getPropertyPath()+" "+v.getMessage()).limit(5).reduce((a,b)->a+"; "+b).orElse("Ungültige Eingabe"));
|
||||
if(request.mode()!=Mode.IMMEDIATE && request.pickupAt()==null)throw invalid("Bitte Datum und Uhrzeit angeben.");
|
||||
if(request.mode()==Mode.IMMEDIATE && request.pickupAt()!=null)throw invalid("Ein Sofortauftrag verwendet die aktuelle Uhrzeit.");
|
||||
if(textOrEmpty(request.dispatchNote()).getBytes(java.nio.charset.StandardCharsets.UTF_8).length>255)
|
||||
throw invalid("Die Dispo-Info ist für das Bestandsfeld zu lang. Bitte den Text kürzen.");
|
||||
if(request.pickupAt()!=null && request.pickupAt().getYear()<2000)throw invalid("Das Auftragsdatum muss ab dem Jahr 2000 liegen.");
|
||||
if(request.pickupAt()!=null && ZoneId.of("Europe/Berlin").getRules().getValidOffsets(request.pickupAt()).size()!=1)
|
||||
throw invalid("Die gewählte Uhrzeit ist durch die Zeitumstellung ungültig oder mehrdeutig. Bitte eine eindeutige Uhrzeit wählen.");
|
||||
if(request.mode()==Mode.RECURRING) {
|
||||
if(request.recurrence()==null)throw invalid("Bitte einen Wiederholungsrhythmus wählen.");
|
||||
Recurrence recurrence=request.recurrence();
|
||||
if(recurrence.rhythm()<=3 && !recurrence.weekdays().contains("1"))throw invalid("Bitte mindestens einen Wochentag wählen.");
|
||||
if(recurrence.endDate()!=null && recurrence.endDate().isBefore(request.pickupAt().toLocalDate()))throw invalid("Das Serienende liegt vor dem Beginn.");
|
||||
} else if(request.recurrence()!=null)throw invalid("Wiederholungen sind nur bei Daueraufträgen möglich.");
|
||||
if(request.mode()==Mode.RETROSPECTIVE && request.courierVehicleId()==null)throw invalid("Eine Nacherfassung benötigt das ausführende Fahrzeug.");
|
||||
if(request.mode()==Mode.RETROSPECTIVE && request.manualDispatch())throw invalid("Eine Nacherfassung ist bereits erledigt und kann nicht zur Disposition stehen.");
|
||||
if(request.manualDispatch() && request.courierVehicleId()!=null)throw invalid("Bitte entweder manuelle Disposition oder ein Fahrzeug wählen.");
|
||||
if(request.payment()==Payment.CASH && (request.cashStop()==null || request.cashStop()>request.stops().size()))
|
||||
throw invalid("Bitte die Station auswählen, an der kassiert wird.");
|
||||
if(request.payment()==Payment.INVOICE && request.cashStop()!=null)throw invalid("Eine Kassierstation ist nur bei Barzahlung möglich.");
|
||||
if(!Set.of(1,2,6).contains(request.origin()))throw invalid("Bitte Telefon, Mail oder Sonstiges als Auftragseingang wählen.");
|
||||
if(request.origin()==6 && textOrEmpty(request.originOther()).isBlank())throw invalid("Bitte die sonstige Auftragsherkunft angeben.");
|
||||
if(request.offer() && (!request.manualDispatch() || request.mode()==Mode.RECURRING || request.mode()==Mode.RETROSPECTIVE || request.insurance()!=null || request.allCouriers()))
|
||||
throw invalid("Ein Angebot benötigt manuelle Disposition und ist nicht mit Dauerauftrag, Nacherfassung, Versicherung oder allen Kurieren kombinierbar.");
|
||||
if(request.allCouriers() && (request.courierVehicleId()!=null || request.manualDispatch() || request.mode()==Mode.RETROSPECTIVE))throw invalid("Alle Kuriere ist nicht mit Fahrzeugzuweisung, manueller Disposition oder Nacherfassung kombinierbar.");
|
||||
if(request.allOccurrences() && (request.mode()!=Mode.RECURRING || request.recurrence().endDate()==null))throw invalid("Bitte ein Serienende für die sofortige Serienanlage angeben.");
|
||||
if(!user.hasRight(38) && (positive(request.pricing().courierPrice()) || positive(request.pricing().courierServicePrice()) || request.pricing().courierMarkup()!=null))
|
||||
user.require(38);
|
||||
}
|
||||
|
||||
private Created persist(LegacyUser user,Request request,Prepared prepared,Quote quote,String requestHash) {
|
||||
long hq=user.headquartersId();
|
||||
boolean retrospective=request.mode()==Mode.RETROSPECTIVE,reservation=request.mode()==Mode.RESERVATION || request.mode()==Mode.RECURRING;
|
||||
boolean longhaul=quote.route()!=null && quote.route().longhaul();
|
||||
List<PhotoChoice> photoChoices=quote.photos()==null?List.of():quote.photos();
|
||||
boolean manual=longhaul || request.offer() || request.manualDispatch() || (prepared.favoured() && catalog.enabled(hq,"MASK_FAVOURED_COURIER_MANUAL") && prepared.vehicle().isEmpty());
|
||||
int status=initialStatus(request.mode(),(int)id(prepared.headquarters(),"hq_workmode"),!prepared.vehicle().isEmpty(),
|
||||
catalog.enabled(hq,"MASK_COURIER_IMMEDIATE"),manual);
|
||||
if(request.allCouriers() && longhaul)throw invalid("Fernaufträge werden manuell disponiert und können nicht an alle Kuriere verteilt werden.");
|
||||
boolean global=request.allCouriers() || status==99;
|
||||
if(global)status=0; // Exclusion list is written atomically before the dispatcher can see this order.
|
||||
int desiredStatus=status;
|
||||
if((quote.photoSet()!=null || !photoChoices.isEmpty()) && !retrospective)status=9;
|
||||
long payer=request.payment()==Payment.CASH?prepared.cashPayer():request.payerCostCenterId();
|
||||
var job=fields("csc_id",0,"hq_id",hq,"hq_id_dispo",quote.route()==null?prepared.dispatchHq():quote.route().dispatchHeadquartersId(),"hq_id_sales",id(prepared.payer(),"hq_id"),
|
||||
"csc_id_payer",payer,"csc_id_payer_cash",request.payment()==Payment.CASH?request.payerCostCenterId():0,
|
||||
"jb_payment",request.payment()==Payment.CASH?2:request.costSplit()?1:0,"vht_id",request.vehicleTypeId(),"jb_status",status,
|
||||
"jb_createtime",prepared.now(),"jb_ordertime",prepared.at(),"jb_reserv",reservation?1:0,"jb_warningtime",request.warningAt(),
|
||||
"emp_id",user.userId(),"jb_version",0,"jb_storno",0,"jb_globaljob",request.allCouriers()?1:0,"jb_offer",request.offer()?1:0,"jb_waitstorno",request.cancelIfUnavailable()?1:0,"jb_waittime",request.acceptUntil(),"jb_longhaul",longhaul?1:0,
|
||||
"jb_service","0","jb_scrm","","jb_origin",request.origin(),"jb_orderer",textOrEmpty(request.orderer()),
|
||||
"jb_commission_no",textOrEmpty(request.reference()),"jb_freetext_1",textOrEmpty(request.notes()),"jb_dispoinfo",textOrEmpty(request.dispatchNote()),
|
||||
"jb_weight",nz(request.weight()),"jb_crvh_length",nz(request.length()),"jb_crvh_width",nz(request.width()),
|
||||
"jb_crvh_height",nz(request.height()),"jb_crvh_position",nz(request.positions()),
|
||||
"jb_fixprice",!quote.batch()?(quote.calculatedBase()!=null?quote.calculatedBase():request.pricing().mode()==PriceMode.FIXED?nz(request.pricing().fixedPrice()):BigDecimal.ZERO):BigDecimal.ZERO,
|
||||
"jb_serviceprice",quote.components().stream().filter(c -> c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add),"jb_totalprice",quote.net(),"jb_subtotalprice",quote.subtotal(),
|
||||
"jb_cr_subprice",longhaul&&!retrospective?BigDecimal.ZERO:positive(request.pricing().courierPrice())?request.pricing().courierPrice():nz(quote.calculatedCourierBase()),"jb_cr_price",quote.courierNet(),"jb_markup",quote.markup(),
|
||||
"jb_sales_tax_rate",quote.taxRate(),"jb_sales_tax_rate_sign",quote.taxSign(),"jb_invmode",quote.invoiceMode(),
|
||||
"jb_postage",number(prepared.payer(),"cmp_postage"),"jb_incomplete",request.incomplete()?1:0,"jb_cr_fav",prepared.favoured()?1:0);
|
||||
if(request.pricing().mode()==PriceMode.SERVICES) {
|
||||
job.put("jb_fixprice",quote.batch()?BigDecimal.ZERO:quote.components().stream().limit(request.services().size()).map(Component::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
job.put("jb_freetext_2",extras.calculatorText(user,request,quote));
|
||||
}
|
||||
if(!textOrEmpty(request.tourName()).isBlank())job.put("jb_tourname",request.tourName().trim());
|
||||
if(request.recurrence()!=null) {
|
||||
job.put("jb_permanent",request.recurrence().rhythm());job.put("jb_permanent2",request.recurrence().weekdays());
|
||||
job.put("jp_permenddat",request.recurrence().endDate()==null?null:request.recurrence().endDate().atTime(23,59,59));
|
||||
}
|
||||
if(!prepared.vehicle().isEmpty()) {
|
||||
job.put("cr_sid",text(prepared.vehicle(),"crvh_sid"));job.put("cr_id_permanent",text(prepared.vehicle(),"crvh_sid"));
|
||||
job.put("cr_id_order",id(prepared.vehicle(),"cr_id"));
|
||||
if(retrospective)job.put("cr_id",id(prepared.vehicle(),"cr_id"));
|
||||
}
|
||||
if(retrospective) {job.put("jb_taketime",prepared.at());job.put("jb_finishtime",prepared.at());job.put("jb_booktime",prepared.now());}
|
||||
String tourData=request.stops().stream().map(s -> s.name()+", "+s.street()+" "+s.houseNumber()+", "+s.postalCode()+" "+s.city()).reduce((a,b)->a+" | "+b).orElse("");
|
||||
// This compact field is parsed by PHP as three parallel, semicolon-separated lists.
|
||||
job.put("jb_tourdata",request.stops().stream().map(Station::postalCode).collect(java.util.stream.Collectors.joining(";"))+"|"
|
||||
+request.stops().stream().map(Station::city).collect(java.util.stream.Collectors.joining(";"))+"|"
|
||||
+request.stops().stream().map(Station::country).collect(java.util.stream.Collectors.joining(";")));
|
||||
job.put("jb_incomplete",isIncomplete(request,prepared.payer(),quote)?1:0);
|
||||
String filterNotes=dispatchDefaults(user,request,prepared,job);
|
||||
job.put("jb_invtext",tourData);job.put("jb_invtext_cr",tourData);
|
||||
if(longhaul) {
|
||||
var filters=new LinkedHashSet<String>();addFilters(filters,String.valueOf(job.get("jb_cr_filter_opt")));filters.add("BWV");
|
||||
job.put("jb_cr_filter_opt",String.join(",",filters));
|
||||
}
|
||||
extras.replaceTemplate(user,request);
|
||||
long orderId=db.insert("job",job);
|
||||
OrderPaymentTerms.capture(db,orderId,prepared.photoCustomer());
|
||||
List<OrderPhotoWorkflows.Selection> photoSelections=new ArrayList<>();
|
||||
List<Long> tourIds=new ArrayList<>();
|
||||
for(int i=0;i<request.stops().size();i++) {
|
||||
Station stop=request.stops().get(i);
|
||||
var addresses=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? ORDER BY ad_id LIMIT 1",
|
||||
stop.street(),stop.postalCode(),stop.city(),stop.country());
|
||||
long addressId=addresses.isEmpty()?db.insert("address",fields("ad_street",stop.street(),"ad_zipcode",stop.postalCode(),"ad_city",stop.city(),"ad_country",stop.country())):id(addresses.getFirst(),"ad_id");
|
||||
String note=textOrEmpty(stop.note());
|
||||
if(request.payment()==Payment.CASH && request.cashStop()==i+1)
|
||||
note+="\n*** ZAHLT BAR ***\n*** Bitte im Auftrag STADTBOTE Bar "+money(quote.net().add(nz(request.toll())))+" EUR zzgl. "+quote.tax()+" EUR MwSt (Gesamt "+quote.gross()+" EUR) kassieren und quittieren. ***";
|
||||
if(i==0) {
|
||||
if(positive(request.toll()))note+="\nMaut / Auslagen: "+money(request.toll())+" EUR";
|
||||
if(catalog.enabled(hq,"MASK_JB_CR_PRICE_2_TR_REMARK") && catalog.enabled(hq,"MASK_CR_PRICE_MODE"))
|
||||
note+="\nFrachtverguetung: "+quote.courierNet()+" EUR";
|
||||
note+=filterNotes;
|
||||
if(catalog.enabled(hq,"MASK_JB_MEASURES_2_TR_REMARK")) {
|
||||
if(positive(request.weight()))note+="\nMaximalgewicht: "+request.weight()+" kg";
|
||||
if(positive(request.length()))note+="\nLänge: "+request.length()+" cm";
|
||||
if(positive(request.width()))note+="\nBreite: "+request.width()+" cm";
|
||||
if(positive(request.height()))note+="\nHöhe: "+request.height()+" cm";
|
||||
if(positive(request.positions()))note+="\nPlätze: "+request.positions();
|
||||
}
|
||||
}
|
||||
long tourId=db.insert("tour",fields("jb_id",orderId,"ad_id",addressId,"tr_sort",i+1,"csc_id",stop.costCenterId()==null?0:stop.costCenterId(),
|
||||
"tr_comp",stop.name(),"tr_hsno",stop.houseNumber(),"tr_person",textOrEmpty(stop.contact()),"tr_phone",textOrEmpty(stop.phone()),
|
||||
"tr_remark",note,"tr_commission_no",textOrEmpty(stop.reference()),"tr_cs_freetext","",
|
||||
"tr_mediationarea_id",stationArea(hq,stop),
|
||||
"tr_ware_from_to",switch(stop.type()){case PICKUP->2;case DELIVERY->1;case BOTH->3;},
|
||||
"tr_status",retrospective?1:0,"tr_finishtime",retrospective?prepared.at():null));
|
||||
tourIds.add(tourId);
|
||||
extras.attachments(tourId,stop.attachments());
|
||||
final int stationIndex=i+1;
|
||||
photoChoices.stream().filter(p -> p.station()==stationIndex).findFirst().ifPresent(p -> photoSelections.add(new OrderPhotoWorkflows.Selection(orderId,tourId,p.number())));
|
||||
}
|
||||
if(!quote.batch() && request.pricing().mode()==PriceMode.SERVICES)db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,0,0,'Fixpreis',0,'',?,0)",orderId,payer,
|
||||
quote.components().stream().limit(request.services().size()).map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add));
|
||||
if(!quote.batch())for(Component component:quote.components().stream().skip(request.pricing().mode()==PriceMode.SERVICES?request.services().size():0).toList())db.update("""
|
||||
INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount)
|
||||
VALUES (?,?,?,0,?,0,?,?,?)
|
||||
""",orderId,payer,component.sequence(),clip(component.name(),50),clip(component.type(),50),component.price(),component.discount());
|
||||
// PHP recognizes a zonal fixed price by these zero-valued 'z' detail rows.
|
||||
if(request.pricing().mode()==PriceMode.ZONE && !quote.batch()) {
|
||||
int sequence=1;
|
||||
for(CalculationLine line:quote.calculation()) {
|
||||
String description=line.quantity().toPlainString()+" × "+line.name().replace("KM-Preis ","")+" à "+money(line.rate()).toPlainString()+" EUR";
|
||||
if(description.length()>50)description=description.substring(0,50);
|
||||
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,?,0,'z',0,0)",orderId,payer,sequence++,description);
|
||||
}
|
||||
}
|
||||
price(orderId,request.pricing().courierMarkup()==null?1:9,quote.courierMarkup(),"");
|
||||
price(orderId,2,quote.courierBase(),"");price(orderId,3,quote.courierService(),"");price(orderId,5,quote.courierDeduction(),"");
|
||||
price(orderId,10,quote.courierSubtotal(),"");
|
||||
if(request.pricing().markup()!=null)price(orderId,8,quote.markup(),"");
|
||||
if(positive(quote.courierAgio()))price(orderId,7,quote.courierAgio(),"");
|
||||
if(quote.sustainability()!=null) {
|
||||
price(orderId,11,quote.sustainability().distanceKm(),quote.sustainability().source().equals("Manuell")?"m":"c");
|
||||
price(orderId,12,quote.sustainability().co2eKg(),quote.sustainability().calculation());
|
||||
// co2e_sendReq.php also snapshots the historical partner commission after calculation.
|
||||
var commission=db.queryForList("SELECT srvprch_price FROM servicepricehistory WHERE srvprch_obj_type='crvh' AND srvprch_obj_id=? AND srvprch_fieldname='crvh_partner_commission' AND srvprch_validfrom<=? ORDER BY srvprch_validfrom DESC LIMIT 1",request.courierVehicleId(),prepared.at());
|
||||
price(orderId,14,commission.isEmpty()?BigDecimal.ZERO:number(commission.getFirst(),"srvprch_price"),"crvh_partner_commission");
|
||||
} else if(quote.route()!=null)price(orderId,11,quote.route().calculationKm(),"");
|
||||
else if(request.distanceKm()!=null)price(orderId,11,request.distanceKm(),"m");
|
||||
if(quote.route()!=null) {
|
||||
if(quote.route().longhaulEligible() && !quote.route().longhaul())
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","longhaul_deselected","gdc_content","true","gdc_context",prepared.now().toString()));
|
||||
saveRouteEvidence(orderId,quote);
|
||||
}
|
||||
accounting.split(orderId,quote);
|
||||
inventory.persist(user,request,orderId,tourIds,prepared.now());
|
||||
for(long child:accounting.children(user,request,prepared,orderId,job,quote))
|
||||
audit.enqueue(child,user,prepared.now(),Mode.RETROSPECTIVE,request.requestId()+":child:"+child);
|
||||
long insuranceId=extras.persist(user,request,prepared,orderId);
|
||||
if(insuranceId>0)audit.enqueue(insuranceId,user,prepared.now(),Mode.RETROSPECTIVE,request.requestId()+":insurance");
|
||||
if(global)extras.globalExclusions(user,request,orderId);
|
||||
photos.enqueue(orderId,photoSelections,desiredStatus,id(prepared.vehicle(),"cr_id"),hq,quote.photoSet()==null?null:quote.photoSet().number());
|
||||
if(status==0 && !prepared.vehicle().isEmpty())db.update("UPDATE courier SET cr_occupied=1 WHERE cr_id=? AND hq_id=?",id(prepared.vehicle(),"cr_id"),hq);
|
||||
for(String key:List.of("automailer","automailer2","automailer3","automailer5","automailer6","automailer7","jb_job_jam"))
|
||||
db.insert("`"+logSchema+"`.semaphor",fields("sp_obj_type","jb","sp_obj_id",orderId,"sp_fieldname",key,"sp_content","","sp_context","INIT","sp_createtime",prepared.now()));
|
||||
db.insert("`"+logSchema+"`.semaphor",fields("sp_obj_type","usr","sp_obj_id",user.userId(),"sp_fieldname","ng_order_request",
|
||||
"sp_content",request.requestId(),"sp_context",hq+"|"+requestHash+"|"+orderId,"sp_createtime",prepared.now()));
|
||||
audit.enqueue(orderId,user,prepared.now(),request.mode(),request.requestId());
|
||||
if(quote.courierLimitExceeded())audit.enqueueLimit(orderId,user,prepared.now(),request.requestId(),quote.courierLimit(),quote.courierNet());
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","jb_invtext_dirty","gdc_content","1","gdc_context",prepared.now().toString()));
|
||||
return new Created(orderId,Long.toString(orderId),status,false,prepared.at(),quote.photoSet()!=null || !photoSelections.isEmpty());
|
||||
}
|
||||
|
||||
record BatchItem(Prepared prepared,Quote quote) {}
|
||||
record Batch(Quote summary,List<BatchItem> items) {}
|
||||
private Batch batch(LegacyUser user,Request request,Prepared prepared,OrderRoutingService.Result route,PhotoPlan photoPlan,boolean lockedInventory,SustainabilityResult emission) {
|
||||
var articleLines=inventory.quote(user,request,lockedInventory);
|
||||
var dates=OrderEntrySeries.dates(db,user,request);var items=new ArrayList<BatchItem>();var schedule=new ArrayList<ScheduleItem>();
|
||||
for(var date:dates) {
|
||||
var at=date==null?prepared.at():date;
|
||||
Prepared entry=new Prepared(prepared.payer(),prepared.headquarters(),prepared.vehicle(),at,prepared.now(),prepared.cashPayer(),prepared.dispatchHq(),prepared.favoured(),prepared.photoCustomer());
|
||||
Quote price=accounting.quote(user,request,(photoPlan==null || photoPlan.set()==null ? pricing.calculate(user,request,prepared.payer(),at,route,photoPlan==null?List.of():photoPlan.choices()) : pricing.calculate(user,request,prepared.payer(),at,route,photoPlan.choices(),photoPlan.set())),articleLines);
|
||||
if(emission!=null)price=sustainability.apply(user,request,entry,price,emission);
|
||||
items.add(new BatchItem(entry,price));
|
||||
schedule.add(new ScheduleItem(at,request.copies(),price.net(),price.gross(),price.courierNet(),price.fingerprint()));
|
||||
}
|
||||
Quote summary=items.getFirst().quote();
|
||||
if(request.allOccurrences())summary=extras.withSchedule(summary,schedule,items.stream().anyMatch(i -> i.quote().courierLimitExceeded()));
|
||||
return new Batch(summary,items);
|
||||
}
|
||||
|
||||
private SustainabilityResult resolveSustainability(LegacyUser user,Request request,Prepared prepared,OrderRoutingService.Result route) {
|
||||
if(request.sustainability()==null)return null;
|
||||
if(sustainability==null)throw new IllegalStateException("Sustainability service missing");
|
||||
return sustainability.resolve(user,request,prepared,route);
|
||||
}
|
||||
private void validateAddresses(Request request) {
|
||||
if(request.acceptUnverifiedAddresses())return;
|
||||
for(Station stop:request.stops()) {
|
||||
var known=db.queryForList("SELECT ad_id FROM address WHERE ad_street=? AND ad_zipcode=? AND ad_city=? AND ad_country=? LIMIT 1",
|
||||
stop.street(),stop.postalCode(),stop.city(),stop.country());
|
||||
if(known.isEmpty())routing.geocode(stop);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveRouteEvidence(long orderId,Quote quote) {
|
||||
try {
|
||||
String content=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of("route",quote.route(),"calculation",quote.calculation()));
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",orderId,"gdc_gen_fieldname","ng_route_calculation","gdc_content",content,"gdc_context","CREATE"));
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Routenbeleg konnte nicht gesichert werden.",e);}
|
||||
}
|
||||
|
||||
private boolean isIncomplete(Request request,Map<String,Object> payer,Quote quote) {
|
||||
if(request.offer() || request.incomplete() || quote.batch() || catalog.enabled(0,"CUSTOMER_MASK_JB_INCOMPLETE_"+id(payer,"cs_id")))return true;
|
||||
var significant=quote.components().stream().filter(c -> !c.name().contains("Grundpreis")).toList();
|
||||
if(significant.isEmpty() || significant.stream().anyMatch(c -> c.price().abs().compareTo(new BigDecimal("0.02"))<0)
|
||||
|| significant.stream().map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add).signum()==0)return true;
|
||||
if(id(payer,"cs_jb_commission_no")==1 && textOrEmpty(request.reference()).isBlank())return true;
|
||||
if(id(payer,"cs_commission_no")==1 && catalog.enabled(0,"CS_MASK_COMM_NO_B_"+id(payer,"cs_id"))) {
|
||||
boolean all=catalog.enabled(0,"CS_MASK_COMM_NO_C_"+id(payer,"cs_id"));
|
||||
if(all?request.stops().stream().anyMatch(s -> textOrEmpty(s.reference()).isBlank()):request.stops().stream().allMatch(s -> textOrEmpty(s.reference()).isBlank()))return true;
|
||||
}
|
||||
if(request.mode()==Mode.RETROSPECTIVE && quote.sustainability()==null) {
|
||||
if(!text(payer,"cmp_sustainability_email").isBlank())return true;
|
||||
if(id(payer,"cs_id_parent")>0) {
|
||||
var parent=db.queryForList("SELECT cmp.cmp_sustainability_email FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id WHERE cs.cs_id=?",id(payer,"cs_id_parent"));
|
||||
if(!parent.isEmpty() && !text(parent.getFirst(),"cmp_sustainability_email").isBlank())return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** job_options.php mediation and mandatory filters; job_edit.js customer defaults. */
|
||||
private String dispatchDefaults(LegacyUser user,Request request,Prepared prepared,Map<String,Object> job) {
|
||||
long hq=user.headquartersId();
|
||||
String zip=request.stops().getFirst().postalCode(),mode=catalog.parameter(hq,"MODE_INTERMEDIATION");
|
||||
long area=0;String name="";
|
||||
if(mode.equals("1")) {
|
||||
var rows=db.queryForList("SELECT srvp_id FROM serviceplz WHERE srvp_plz=?",zip);
|
||||
if(!rows.isEmpty())area=id(rows.getFirst(),"srvp_id");
|
||||
name=zip;
|
||||
} else if(mode.equals("2") || mode.equals("3")) {
|
||||
var rows=db.queryForList("""
|
||||
SELECT a.srvpa_id,a.srvpa_name FROM serviceplz p
|
||||
JOIN serviceplzareamapping m ON m.srvp_id=p.srvp_id
|
||||
JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE p.srvp_plz=? AND a.hq_id=?
|
||||
""",zip,hq);
|
||||
if(!rows.isEmpty()) {area=id(rows.getFirst(),"srvpa_id");name=text(rows.getFirst(),"srvpa_name");}
|
||||
}
|
||||
job.put("jb_mediationarea_id",area);job.put("jb_mediationarea_name",name);
|
||||
var defaults=new LinkedHashSet<String>(request.courierFilters());addFilters(defaults,text(prepared.payer(),"cs_filter"));
|
||||
for(Station stop:request.stops())if(stop.costCenterId()!=null) {
|
||||
var customer=catalog.costCenter(user,stop.costCenterId(),false);
|
||||
addFilters(defaults,text(customer,"cs_filter"));
|
||||
if(catalog.enabled(0,"CUSTOMER_MASK_JB_INCOMPLETE_"+id(customer,"cs_id")))job.put("jb_incomplete",1);
|
||||
}
|
||||
var required=new LinkedHashSet<String>();var optional=new LinkedHashSet<String>();
|
||||
var filterRows=db.queryForList("SELECT crf_short,crf_status,crf_longtext FROM courierfilter ORDER BY crf_short");
|
||||
for(var filter:filterRows)if(defaults.contains(text(filter,"crf_short")))
|
||||
(id(filter,"crf_status")==1?required:optional).add(text(filter,"crf_short"));
|
||||
addFilters(required,catalog.parameter(hq,"MASK_MANDATORY_FILTERS"));
|
||||
if(request.stops().stream().anyMatch(s -> !s.country().equals("DE"))) {
|
||||
required.add("EU");
|
||||
String since=catalog.parameter(0,"MASK_FILTER_AL_DATE");
|
||||
// PHP strtotime("") behaves like an already elapsed threshold.
|
||||
if(since.isBlank() || !configuredDate(since).isAfter(prepared.at().toLocalDate()))required.add("AL");
|
||||
} else {required.remove("EU");required.remove("AL");}
|
||||
job.put("jb_cr_filter",String.join(",",required));job.put("jb_cr_filter_opt",String.join(",",optional));
|
||||
StringBuilder notes=new StringBuilder();
|
||||
for(var filter:filterRows)if(required.contains(text(filter,"crf_short")) || optional.contains(text(filter,"crf_short"))) {
|
||||
String description=text(filter,"crf_longtext");if(!description.isBlank())notes.append("\n").append(description);
|
||||
}
|
||||
if(optional.contains("LZ"))notes.append("\n*** LIEFERSCHEIN ZUR ZENTRALE STADTBOTE ***");
|
||||
return notes.toString();
|
||||
}
|
||||
private long stationArea(long hq,Station stop) { return LegacyAddressTariffs.postal(db,hq,stop); }
|
||||
private static void addFilters(Set<String> filters,String value) {
|
||||
for(String filter:value.split(","))if(!filter.isBlank())filters.add(filter.trim());
|
||||
}
|
||||
private static LocalDate configuredDate(String value) {
|
||||
try {return LocalDate.parse(value.substring(0,10));}
|
||||
catch(RuntimeException ignored) {
|
||||
try {return LocalDate.parse(value,java.time.format.DateTimeFormatter.ofPattern("dd.MM.uuuu"));}
|
||||
catch(RuntimeException e) {throw invalid("Das Datum für MASK_FILTER_AL_DATE im Bestand ist ungültig.");}
|
||||
}
|
||||
}
|
||||
|
||||
static int initialStatus(Mode mode,int workMode,boolean vehicle,boolean immediateCourier,boolean manual) {
|
||||
if(mode==Mode.RETROSPECTIVE)return 2;
|
||||
if(manual)return 9;
|
||||
if(vehicle) {
|
||||
if(mode==Mode.IMMEDIATE || workMode==1 || immediateCourier)return 0;
|
||||
if(workMode==2)return 8;
|
||||
}
|
||||
return workMode==1?9:workMode==2?8:99;
|
||||
}
|
||||
private static String clip(String value,int limit) {return value.length()>limit?value.substring(0,limit):value;}
|
||||
private void price(long id,int kind,BigDecimal value,String remark) {
|
||||
db.update("INSERT INTO jobprice (jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,?,?,?)",id,kind,value,remark);
|
||||
}
|
||||
private void checkCourierLimit(LegacyUser user,Request request,Quote quote) {
|
||||
if(!quote.courierLimitExceeded())return;
|
||||
user.require(39);
|
||||
if(!request.confirmCourierLimit())throw invalid("Die Frachtvergütung liegt über der hinterlegten Grenze. Bitte die Überschreitung ausdrücklich bestätigen.");
|
||||
}
|
||||
private void requireTransactionalTables() {
|
||||
var rows=db.queryForList("""
|
||||
SELECT TABLE_NAME,ENGINE FROM information_schema.TABLES WHERE
|
||||
(TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('job','tour','address','tourservice','jobprice','genericdatacontainer','courier','user'))
|
||||
OR (TABLE_SCHEMA=? AND TABLE_NAME='semaphor')
|
||||
""",logSchema);
|
||||
if(rows.size()!=9 || rows.stream().anyMatch(row -> !text(row,"ENGINE").equalsIgnoreCase("InnoDB")))
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Die Auftragserfassung benötigt transaktionsfähige Bestandstabellen. Es wurden keine Daten verändert.");
|
||||
}
|
||||
static LinkedHashMap<String,Object> fields(Object... pairs) {
|
||||
var result=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)result.put((String)pairs[i],pairs[i+1]);return result;
|
||||
}
|
||||
static String textOrEmpty(String text) {return text==null?"":text;}
|
||||
static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/entry")
|
||||
public class OrderEntryTemplates {
|
||||
private final DatabaseService db;private final OrderEntryCatalog catalog;
|
||||
public OrderEntryTemplates(DatabaseService db,OrderEntryCatalog catalog) {this.db=db;this.catalog=catalog;}
|
||||
@GetMapping("/saved-orders") public List<SavedOrder> search(Authentication auth,@RequestParam(defaultValue="") String q,@RequestParam(required=false) Long payerId,@RequestParam(defaultValue="false") boolean templates) {
|
||||
LegacyUser user=user(auth);user.require(7);
|
||||
if(payerId!=null)catalog.costCenter(user,payerId,false);
|
||||
var args=new ArrayList<Object>();args.add(user.headquartersId());args.add(OrderEntryCatalog.search(q));args.add(OrderEntryCatalog.search(q));
|
||||
if(payerId!=null){args.add(payerId);args.add(payerId);}
|
||||
return db.queryForList("""
|
||||
SELECT j.jb_id,j.jb_tourname,j.jb_ordertime,cmp.cmp_comp FROM job j
|
||||
JOIN costcenter cc ON cc.csc_id=IF(j.csc_id_payer_cash>0,j.csc_id_payer_cash,j.csc_id_payer)
|
||||
JOIN customer cs ON cs.cs_id=cc.cs_id JOIN company cmp ON cmp.cmp_id=cs.cmp_id
|
||||
WHERE j.hq_id=? AND (CAST(j.jb_id AS CHAR) LIKE ? OR j.jb_tourname LIKE ?)
|
||||
"""+(payerId==null?"":" AND (j.csc_id_payer=? OR j.csc_id_payer_cash=?)")+(templates?" AND LENGTH(j.jb_tourname)>0":"")+" ORDER BY j.jb_id DESC LIMIT 50",args.toArray())
|
||||
.stream().map(r -> new SavedOrder(id(r,"jb_id"),text(r,"jb_tourname"),text(r,"cmp_comp"),dateTime(r,"jb_ordertime"))).toList();
|
||||
}
|
||||
@GetMapping("/saved-orders/{id}") public Map<String,Object> load(Authentication auth,@PathVariable long id) {
|
||||
LegacyUser user=user(auth);user.require(7);
|
||||
return db.readTransaction(() -> {
|
||||
var job=new OrderEntryExtras(db,catalog).requireOrder(user,id);
|
||||
long payer=id(job,"csc_id_payer_cash")>0?id(job,"csc_id_payer_cash"):id(job,"csc_id_payer");
|
||||
var center=catalog.costCenter(user,payer,id(job,"jb_payment")!=2);
|
||||
var payerDto=new CostCenter(payer,id(center,"cs_id"),text(center,"cs_eid"),text(center,"cmp_comp"),text(center,"csc_name"),nullableId(center,"csc_id_payer"),number(center,"cs_fixprice_discount"),nullableId(center,"tx_id"));
|
||||
var stops=db.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",id).stream().map(r -> fields(
|
||||
"costCenterId",nullableId(r,"csc_id"),"type",id(r,"tr_ware_from_to")==3?"BOTH":id(r,"tr_ware_from_to")==1?"DELIVERY":"PICKUP",
|
||||
"name",text(r,"tr_comp"),"street",text(r,"ad_street"),"houseNumber",text(r,"tr_hsno"),"postalCode",text(r,"ad_zipcode"),"city",text(r,"ad_city"),
|
||||
"country",text(r,"ad_country").equals("D")?"DE":text(r,"ad_country"),"contact",text(r,"tr_person"),"phone",text(r,"tr_phone"),"note",text(r,"tr_remark"),"reference",text(r,"tr_commission_no"),
|
||||
"billingPostalId",nullableId(r,"tr_mediationarea_id"),"photoWorkflow",null,"attachments",List.of())).toList();
|
||||
Map<String,Object> meta=new HashMap<>();
|
||||
for(var r:db.queryForList("SELECT gdc_gen_fieldname,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('jb_toll','jb_hiddenFreetext_1','jb_origin_other') ORDER BY gdc_id",id))meta.put(text(r,"gdc_gen_fieldname"),text(r,"gdc_content"));
|
||||
var serviceItems=services(user,payer,text(job,"jb_freetext_2"));
|
||||
if(id(job,"jb_version")==0) {
|
||||
var seedRows=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_entry_seed' ORDER BY gdc_id",id);
|
||||
if(!seedRows.isEmpty())try {
|
||||
String seed=seedRows.stream().map(r -> text(r,"gdc_content")).collect(java.util.stream.Collectors.joining());
|
||||
var original=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().readValue(seed,Request.class);
|
||||
if(original.stops().size()==stops.size())for(int i=0;i<stops.size();i++)stops.get(i).put("note",original.stops().get(i).note());
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw invalid("Die ursprünglichen Stationshinweise konnten nicht gelesen werden.");}
|
||||
}
|
||||
var articles=new ArrayList<Map<String,Object>>();
|
||||
for(var row:db.queryForList("SELECT ta.at_id,ta.tr_sort,ta.trat_quantity,ta.trat_remark,ta.trat_name,ta.trat_weight,ta.trat_packingpieces,ta.trat_price FROM tourarticle ta WHERE ta.jb_id=? ORDER BY ta.tr_sort,ta.trat_sort,ta.trat_id",id)) {
|
||||
if(id(row,"at_id")<=0) {
|
||||
articles.add(fields("article",null,"freeArticle",new FreeArticle(text(row,"trat_name"),number(row,"trat_weight"),(int)id(row,"trat_packingpieces"),number(row,"trat_price")),
|
||||
"station",id(row,"tr_sort"),"quantity",id(row,"trat_quantity"),"remark",text(row,"trat_remark")));
|
||||
continue;
|
||||
}
|
||||
var article=new OrderEntryInventory(db,catalog).article(user,id(row,"at_id"));
|
||||
articles.add(fields("article",new ArticleOption(id(article,"at_id"),text(article,"at_eid"),text(article,"at_name"),id(article,"at_serialno")!=0,number(article,"at_totalweight"),(int)id(article,"at_packingpieces")),
|
||||
"station",id(row,"tr_sort"),"quantity",id(row,"trat_quantity"),"remark",text(row,"trat_remark")));
|
||||
}
|
||||
// Existing serial numbers and inventory movements must never be replayed by copying a tour.
|
||||
// Operational state, vehicle assignment, attachments, deadlines and insurance never carry over.
|
||||
return fields("articles",articles,"costSplit",id(job,"jb_payment")==1,"services",serviceItems,"payer",payerDto,"sourceOrderId",id,"tourName",text(job,"jb_tourname"),"stops",stops,
|
||||
"vehicleTypeId",id(job,"vht_id"),"payment",id(job,"jb_payment")==2?"CASH":"INVOICE",
|
||||
"orderer",text(job,"jb_orderer"),"reference",text(job,"jb_commission_no"),"notes",text(job,"jb_freetext_1"),"dispatchNote",text(job,"jb_dispoinfo"),
|
||||
"weight",number(job,"jb_weight"),"length",number(job,"jb_crvh_length"),"width",number(job,"jb_crvh_width"),"height",number(job,"jb_crvh_height"),"positions",number(job,"jb_crvh_position"),
|
||||
"fixedPrice",number(job,"jb_fixprice"),"servicePrice",number(job,"jb_serviceprice"),"toll",meta.getOrDefault("jb_toll",""),"invoiceOnlyText",meta.getOrDefault("jb_hiddenFreetext_1",""));
|
||||
});
|
||||
}
|
||||
List<ServiceItem> services(LegacyUser user,long payer,String calc) {
|
||||
var serviceItems=new ArrayList<ServiceItem>();
|
||||
String[] parts=calc.split("²",-1);
|
||||
if(parts.length==4 && !parts[0].isBlank()) {
|
||||
var allowed=catalog.services(user,payer);String[] names=parts[0].split("¹",-1),types=parts[1].split("¹",-1),quantities=parts[2].split("¹",-1);
|
||||
if(names.length!=types.length || names.length!=quantities.length)throw invalid("Die gespeicherte Kalkulation ist unvollständig. Bitte den Quellauftrag in VoTiAn prüfen.");
|
||||
for(int i=0;i<names.length;i++) {
|
||||
final int index=i;var option=allowed.stream().filter(o -> o.service().equals(names[index]) && o.type().equals(types[index])).findFirst().orElseThrow(() -> invalid("Eine gespeicherte Leistungsposition ist für diesen Kunden nicht mehr verfügbar."));
|
||||
try {serviceItems.add(new ServiceItem(option.serviceId(),option.typeId(),new java.math.BigDecimal(quantities[i].replace(',','.'))));}
|
||||
catch(NumberFormatException e){throw invalid("Eine gespeicherte Leistungsmenge ist ungültig.");}
|
||||
}
|
||||
}
|
||||
return serviceItems;
|
||||
}
|
||||
@GetMapping("/courier-filters") public List<CourierFilter> filters(Authentication auth) {return catalog.filters(user(auth));}
|
||||
@GetMapping("/billing-postal-codes") public List<Option> postcodes(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.billingPostalCodes(user(auth),q);}
|
||||
@GetMapping("/addresses") public List<Address> addresses(Authentication auth,@RequestParam(defaultValue="") String q) {return catalog.searchAddresses(user(auth),q);}
|
||||
@GetMapping("/cost-centers/{id}/defaults") public CustomerDefaults defaults(Authentication auth,@PathVariable long id) {return catalog.defaults(user(auth),id);}
|
||||
@GetMapping("/cost-centers/{id}/services") public List<ServiceOption> services(Authentication auth,@PathVariable long id) {return catalog.services(user(auth),id);}
|
||||
@GetMapping("/upload") public Map<String,String> upload(Authentication auth) {
|
||||
user(auth).require(7);String url=catalog.parameter(0,"URL_DOC_UPLOAD");
|
||||
if(!url.isBlank())try {var uri=java.net.URI.create(url);if(!Set.of("https","http").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null)url="";}catch(IllegalArgumentException e){url="";}
|
||||
return Map.of("url",url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.time.Duration;
|
||||
|
||||
/** Only server-owned integration URLs are accepted. Never expose payloads, keys or remote errors. */
|
||||
@Component
|
||||
public class OrderIntegrationClient {
|
||||
private final ObjectMapper json;
|
||||
private final HttpClient http=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
public OrderIntegrationClient(ObjectMapper json) {this.json=json;}
|
||||
JsonNode get(String url,String label) {return exchange(url,null,label,"",java.util.Map.of());}
|
||||
JsonNode post(String url,Object payload,String label) {return exchange(url,payload,label,"",java.util.Map.of());}
|
||||
JsonNode postAuthenticated(String url,Object payload,String label,String username,String password) {
|
||||
if(!url.startsWith("https://"))throw unavailable(label+" benötigt einen HTTPS-Endpunkt.");
|
||||
if(username.contains(":"))throw unavailable(label+" ist nicht korrekt konfiguriert.");
|
||||
String auth="Basic "+java.util.Base64.getEncoder().encodeToString((username+":"+password).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
return exchange(url,payload,label,auth,java.util.Map.of());
|
||||
}
|
||||
JsonNode googlePlaces(String path,Object payload,String key,String fields) {
|
||||
return exchange("https://places.googleapis.com/v1/"+path,payload,"Die Adressvorschläge","",java.util.Map.of("X-Goog-Api-Key",key,"X-Goog-FieldMask",fields));
|
||||
}
|
||||
private JsonNode exchange(String url,Object payload,String label,String authorization,java.util.Map<String,String> headers) {
|
||||
try {
|
||||
URI uri=URI.create(url);
|
||||
if(!java.util.Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null)
|
||||
throw unavailable(label+" ist nicht korrekt konfiguriert.");
|
||||
var builder=HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(12)).header("Accept","application/json");
|
||||
headers.forEach(builder::header);
|
||||
if(!authorization.isEmpty())builder.header("Authorization",authorization);
|
||||
if(payload==null)builder.GET();
|
||||
else builder.header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(payload)));
|
||||
// Read in the HTTP subscriber so the request timeout also covers the body.
|
||||
var response=http.send(builder.build(),limitedBody());
|
||||
if(response.statusCode()<200 || response.statusCode()>=300)throw unavailable(label+" ist momentan nicht verfügbar.");
|
||||
byte[] body=response.body();
|
||||
return body.length==0?json.createObjectNode():json.readTree(body);
|
||||
} catch(InterruptedException e) {Thread.currentThread().interrupt();throw unavailable(label+" wurde unterbrochen.");}
|
||||
catch(ResponseStatusException e) {throw e;}
|
||||
catch(Exception e) {throw unavailable(label+" ist momentan nicht verfügbar oder hat ungültige Daten geliefert.");}
|
||||
}
|
||||
private static HttpResponse.BodyHandler<byte[]> limitedBody() {
|
||||
return info -> new HttpResponse.BodySubscriber<>() {
|
||||
final java.util.concurrent.CompletableFuture<byte[]> result=new java.util.concurrent.CompletableFuture<>();
|
||||
final java.io.ByteArrayOutputStream data=new java.io.ByteArrayOutputStream();
|
||||
java.util.concurrent.Flow.Subscription subscription;
|
||||
public java.util.concurrent.CompletionStage<byte[]> getBody(){return result;}
|
||||
public void onSubscribe(java.util.concurrent.Flow.Subscription s){subscription=s;s.request(1);}
|
||||
public void onNext(java.util.List<java.nio.ByteBuffer> buffers){
|
||||
for(var buffer:buffers){if(data.size()+buffer.remaining()>2_000_000){subscription.cancel();result.completeExceptionally(new IllegalStateException("Response too large"));return;}
|
||||
byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);data.writeBytes(bytes);}
|
||||
subscription.request(1);
|
||||
}
|
||||
public void onError(Throwable error){result.completeExceptionally(error);}
|
||||
public void onComplete(){result.complete(data.toByteArray());}
|
||||
};
|
||||
}
|
||||
static ResponseStatusException unavailable(String message){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,message);}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.MasterData.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
|
||||
@Service
|
||||
public class OrderOperations {
|
||||
final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;final LegacyOrderPricing pricing;
|
||||
public OrderOperations(DatabaseService db,BusinessSupport support,OrderEntryCatalog catalog,LegacyOrderPricing pricing){this.db=db;this.support=support;this.catalog=catalog;this.pricing=pricing;}
|
||||
static final List<Field> JOB_FIELDS=List.of(f("jb_ordertime","Auftragszeit","Auftrag","datetime-local",0,true),f("jb_warningtime","Warnzeit","Auftrag","datetime-local",0,false),f("jb_tourname","Tourenname","Auftrag","text",50,false),f("jb_orderer","Besteller","Auftrag","text",50,false),f("jb_commission_no","Kommissionsnummer","Auftrag","text",30,false),f("jb_dispoinfo","Dispo-Info","Auftrag","textarea",255,false),f("jb_freetext_3","Interne Bemerkung","Auftrag","textarea",10000,false),f("jb_freetext_1","Freitext","Auftrag","textarea",10000,false),f("jb_weight","Gewicht (kg)","Ladung","integer",100000,false),f("jb_crvh_length","Länge (cm)","Ladung","integer",10000,false),f("jb_crvh_width","Breite (cm)","Ladung","integer",10000,false),f("jb_crvh_height","Höhe (cm)","Ladung","integer",10000,false),f("jb_crvh_position","Stellplätze","Ladung","integer",1000,false),f("jb_incomplete","Nachbearbeitung erforderlich","Abrechnung","check",1,false),f("jb_invtext","Kundenrechnungstext","Abrechnung","textarea",20000,false),f("jb_invtext_cr","Kurierabrechnungstext","Abrechnung","textarea",20000,false),f("jb_cr_remark","Kurierbemerkung","Abrechnung","text",255,false));
|
||||
static final List<Field> STOP_FIELDS=List.of(f("tr_comp","Firma / Name","Station","text",50,true),f("tr_comp2","Zusatz","Station","text",50,false),f("ad_street","Straße","Adresse","text",50,true),f("tr_hsno","Hausnummer","Adresse","text",10,false),f("ad_zipcode","PLZ","Adresse","text",10,true),f("ad_city","Ort","Adresse","text",50,true),f("ad_country","Land (ISO)","Adresse","country",2,true),f("tr_person","Kontakt","Station","text",50,false),f("tr_phone","Telefon","Station","text",20,false),f("tr_remark","Bemerkung","Station","textarea",10000,false),f("tr_commission_no","Kommissionsnummer","Station","text",30,false),choice("tr_ware_from_to","Stationstyp","Station","2","Abholung","1","Zustellung","3","Abholung und Zustellung"));
|
||||
public record Station(long id,Map<String,Object> values){}
|
||||
public record Edit(String requestId,String revision,Map<String,Object> values,List<Station> stations){}
|
||||
public record Price(String requestId,String revision,BigDecimal customerNet,BigDecimal courierNet,String reason){}
|
||||
public record Action(String requestId,String revision,String action,Long vehicleId,Long stationId,String recipient,String reason,boolean chargeable,boolean endSeries){}
|
||||
record Snapshot(Map<String,Object> job,List<Map<String,Object>> stops,List<Map<String,Object>> prices,List<Map<String,Object>> invoices,List<Map<String,Object>> payments,List<Map<String,Object>> priceDetails){}
|
||||
Snapshot snapshot(LegacyUser u,long id,boolean lock){
|
||||
String sql="SELECT * FROM job WHERE jb_id=? AND hq_id=?";var job=one(lock?db.lockRows(sql,id,u.headquartersId()):db.queryForList(sql,id,u.headquartersId()));
|
||||
String tours="SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city,a.ad_country FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id";
|
||||
return new Snapshot(job,lock?db.lockRows(tours,id):db.queryForList(tours,id),(lock?db.lockRows("SELECT * FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",id):db.queryForList("SELECT * FROM tourservice WHERE jb_id=? ORDER BY tr_sort,csc_id,srv_id,srvt_id",id)),db.queryForList("SELECT * FROM invoice WHERE jb_id=?",id),(lock?db.lockRows("SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_id",id):db.queryForList("SELECT * FROM jobpayment WHERE jb_id=? ORDER BY jbp_id",id)),(lock?db.lockRows("SELECT * FROM jobprice WHERE jb_id=? ORDER BY mt_sort,jbprc_reference",id):db.queryForList("SELECT * FROM jobprice WHERE jb_id=? ORDER BY mt_sort,jbprc_reference",id)));
|
||||
}
|
||||
static Map<String,Object> editable(List<Field> fields,Map<String,Object> row){var v=new LinkedHashMap<String,Object>();for(var f:fields){Object x=row.get(f.key());if(x instanceof Boolean b)x=b?1:0;if(x instanceof java.sql.Timestamp t)x=t.toLocalDateTime().toString();if(x==null)x=f.type().equals("check")||f.type().equals("integer")?0:"";if(f.key().equals("ad_country")&&x.equals("D"))x="DE";v.put(f.key(),x);}return v;}
|
||||
public Map<String,Object> view(LegacyUser u,long id){u.require(4);return db.readTransaction(()->{var s=snapshot(u,id,false);var corrections=db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4) ORDER BY jb_id",id);return fields("creditId",corrections.isEmpty()?0:id(corrections.getFirst(),"jb_id"),"id",id,"revision",revision(s),"fields",JOB_FIELDS,"stopFields",STOP_FIELDS,"values",editable(JOB_FIELDS,s.job()),"stations",s.stops().stream().map(t->fields("id",id(t,"tr_id"),"completed",id(t,"tr_status")==1,"recipient",text(t,"tr_signname"),"values",editable(STOP_FIELDS,t))).toList(),"prices",s.prices(),"status",id(s.job(),"jb_status"),"storno",id(s.job(),"jb_storno"),"courier",text(s.job(),"cr_sid"),"customerNet",decimal(s.job(),"jb_totalprice"),"courierNet",decimal(s.job(),"jb_cr_price"),"readOnly",exported(s.job())||!s.invoices().isEmpty()||!corrections.isEmpty(),"canEdit",u.hasRight(7),"canPrice",u.hasRight(38));});}
|
||||
static boolean exported(Map<String,Object> job){String time=text(job,"jb_export_time");return !time.isBlank()&&!time.startsWith("0000-")&&!time.startsWith("9999-");}
|
||||
void writable(Snapshot s){if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",id(s.job(),"jb_id")).isEmpty())throw bad("Für diesen Auftrag besteht bereits eine Gegenbuchung.");if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",id(s.job(),"jb_id")).isEmpty())throw bad("Für diesen Auftrag läuft eine Rechnungszuordnung.");if(exported(s.job())||!s.invoices().isEmpty())throw bad("Der Auftrag wurde bereits exportiert oder einer Rechnung zugeordnet. Für eine Korrektur ist eine Gutschrift erforderlich.");if(id(s.job(),"jb_storno")!=0)throw bad("Stornierte Aufträge können nicht erneut bearbeitet werden.");var lock=dateTime(s.job(),"jb_locktime");if(lock!=null&&lock.isAfter(LocalDateTime.now().minusMinutes(5)))throw bad("Der Auftrag wird gerade in der alten Anwendung bearbeitet. Bitte dort schließen und erneut laden.");}
|
||||
public Map<String,Object> edit(LegacyUser u,long id,Edit r){u.require(4);u.require(7);if(r==null||r.values()==null||r.stations()==null)throw bad("Auftragsdaten fehlen.");token(r.requestId());var values=validateFields(JOB_FIELDS,"orders",r.values());
|
||||
if(r.stations().isEmpty()||r.stations().size()>100)throw bad("Eine Tour benötigt 1 bis 100 Stationen.");
|
||||
var stops=r.stations().stream().map(s->validateFields(STOP_FIELDS,"orders",s.values())).toList();
|
||||
if(stops.size()>1&&(((Number)stops.getFirst().get("tr_ware_from_to")).intValue()==1||stops.stream().noneMatch(s->((Number)s.get("tr_ware_from_to")).intValue()!=2)))throw bad("Die Tour benötigt eine Abholung am Anfang und eine Zustellung.");
|
||||
transactional();db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,r.requestId(),List.of(id,r))!=null)return null;var s=snapshot(u,id,true);unchanged(s,r.revision());writable(s);
|
||||
var original=new HashMap<Long,Map<String,Object>>();s.stops().forEach(t->original.put(id(t,"tr_id"),t));var seen=new HashSet<Long>();
|
||||
// Station-dependent article scans, prices and signatures must keep their station identity.
|
||||
boolean structureChanged=r.stations().size()!=s.stops().size();for(int i=0;i<r.stations().size();i++)if(r.stations().get(i).id()!=0){var t=original.get(r.stations().get(i).id());if(t==null||!seen.add(r.stations().get(i).id()))throw bad("Ungültige oder doppelte Station.");if(id(t,"tr_sort")!=i+1)structureChanged=true;}else structureChanged=true;
|
||||
if(structureChanged&&id(s.job(),"jb_status")==2)throw bad("Die Stationsfolge eines erledigten Auftrags bleibt als Nachweis erhalten.");
|
||||
if(structureChanged&&(!db.queryForList("SELECT trat_id FROM tourarticle WHERE jb_id=? LIMIT 1",id).isEmpty()||s.prices().stream().anyMatch(p->id(p,"tr_sort")>0)))throw bad("Diese Tour enthält stationsbezogene Artikel oder Preispositionen. Die Reihenfolge muss erhalten bleiben.");
|
||||
for(var old:s.stops())if(!seen.contains(id(old,"tr_id"))){if(id(old,"tr_status")==1)throw bad("Abgeschlossene Stationen bleiben als Zustellnachweis erhalten.");db.update("DELETE FROM tour WHERE tr_id=? AND jb_id=?",id(old,"tr_id"),id);}
|
||||
for(int i=0;i<stops.size();i++){var input=stops.get(i);long station=r.stations().get(i).id();var old=original.getOrDefault(station,Map.of());if(id(old,"tr_status")==1)for(String key:List.of("tr_comp","tr_comp2","tr_hsno","ad_street","ad_zipcode","ad_city","ad_country","tr_ware_from_to"))if(!Objects.toString(editable(STOP_FIELDS,old).get(key),"").equals(Objects.toString(input.get(key),"")))throw bad("Adresse und Typ abgeschlossener Stationen bleiben als Nachweis erhalten.");var address=prefix(input,"ad_");long addressId=id(old,"ad_id");if(station==0||address.entrySet().stream().anyMatch(e->!Objects.toString(old.get(e.getKey()),"").equals(e.getValue().toString())))addressId=db.insert("address",address);var tour=prefix(input,"tr_");if(addressId!=id(old,"ad_id"))tour.put("tr_mediationarea_id",0);tour.putAll(fields("ad_id",addressId,"tr_sort",i+1));if(station==0){tour.putAll(fields("jb_id",id,"tr_status",0,"tr_cs_freetext",""));db.insert("tour",tour);}else update(db,"tour","tr_id",station,tour);}
|
||||
if(id(s.job(),"jb_status")==1&&db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? AND COALESCE(tr_status,0)<>1 LIMIT 1",id).isEmpty())throw bad("Bitte den Auftrag zuerst regulär abschließen; die letzte offene Station kann nicht entfernt werden.");
|
||||
boolean routeChanged=structureChanged;for(int i=0;i<stops.size();i++){var old=original.getOrDefault(r.stations().get(i).id(),Map.of());for(String key:List.of("ad_street","tr_hsno","ad_zipcode","ad_city","ad_country"))if(!Objects.toString(editable(STOP_FIELDS,old).get(key),"").equals(Objects.toString(stops.get(i).get(key),"")))routeChanged=true;}if(routeChanged||!Objects.toString(editable(JOB_FIELDS,s.job()).get("jb_ordertime"),"").equals(Objects.toString(values.get("jb_ordertime"),"")))values.put("jb_incomplete",1);
|
||||
values.put("jb_version",id(s.job(),"jb_version")+1);update(db,"job","jb_id",id,values);if(id(s.job(),"jb_status")==1)command(u,id(s.job(),"cr_id"),"5",Long.toString(id));
|
||||
support.audit(u,id,0,0,2,"Auftrag bearbeitet; Stationen und Texte aktualisiert",r.requestId());support.receipt(u,r.requestId(),List.of(id,r),id);return null;});return view(u,id);
|
||||
}
|
||||
public Map<String,Object> action(LegacyUser u,long id,Action r){u.require(4);u.require(7);if(r==null||r.action()==null)throw bad("Aktion fehlt.");token(r.requestId());transactional();long result=db.writeTransaction(()->{lockHeadquarters(db,u);Long receipt=support.receipt(u,r.requestId(),List.of(id,r));if(receipt!=null)return receipt;var s=snapshot(u,id,true);unchanged(s,r.revision());if(!r.action().equals("CREDIT"))writable(s);else if(!db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_invoice_lock'",id).isEmpty())throw bad("Rechnungszuordnung läuft noch.");long state=id(s.job(),"jb_status"),courier=id(s.job(),"cr_id"),offered=id(s.job(),"cr_id_order");long resultId=id;var changes=new LinkedHashMap<String,Object>();int operation=2;
|
||||
switch(r.action()){
|
||||
case "ASSIGN"->{if(state==2)throw bad("Erledigte Aufträge können nicht neu disponiert werden.");if(r.vehicleId()==null)throw bad("Bitte ein Fahrzeug wählen.");var v=catalog.vehicle(u,r.vehicleId());long next=id(v,"cr_id");var cr=one(db.lockRows("SELECT cr_sid FROM courier WHERE cr_id=? AND hq_id=?",next,u.headquartersId()));boolean online=text(cr,"cr_sid").equals(text(v,"crvh_sid"));for(long previous:new TreeSet<>(List.of(courier,offered)))if(previous>0&&previous!=next)command(u,previous,"4",Long.toString(id));changes.putAll(fields("cr_id",online?0:next,"cr_id_order",next,"cr_sid",text(v,"crvh_sid"),"vht_id_real",id(v,"vht_id"),"jb_status",online?0:1,"jb_autoranking",0,"jb_globaljob",0,"jb_taketime",online?null:LocalDateTime.now()));db.update("UPDATE courier SET cr_occupied=1 WHERE cr_id=?",next);db.update("DELETE FROM autoranking WHERE jb_id=?",id);insertWithoutKey(db,"autoranking",fields("jb_id",id,"cr_id",next,"ar_challenge",0,"ar_lastassigntime",LocalDateTime.now(),"ar_cr_order",id(s.job(),"jb_reserv")));operation=7;}
|
||||
case "RECALL"->{if(state==2)throw bad("Erledigte Aufträge können nicht zurückgerufen werden.");for(long previous:new TreeSet<>(List.of(courier,offered)))command(u,previous,"4",Long.toString(id));changes.putAll(fields("cr_id",0,"cr_id_order",0,"cr_sid","","jb_status",9,"jb_autoranking",0,"jb_globaljob",0,"jb_taketime",null));db.update("DELETE FROM autoranking WHERE jb_id=?",id);operation=8;}
|
||||
case "ACCEPT"->{if(state!=0||offered<=0)throw bad("Nur ein angebotener Auftrag kann angenommen werden.");changes.putAll(fields("cr_id",offered,"jb_status",1,"jb_taketime",LocalDateTime.now()));operation=3;}
|
||||
case "COMPLETE","COMPLETE_STOP"->{if(state!=1||courier<=0)throw bad("Zum Abschließen muss der Auftrag angenommen und einem Kurier zugewiesen sein.");String recipient=required(r.recipient(),50,"Empfänger / Erledigungsnachweis");LocalDateTime now=LocalDateTime.now().withNano(0);
|
||||
if(r.action().equals("COMPLETE_STOP")){if(r.stationId()==null||s.stops().stream().noneMatch(t->id(t,"tr_id")==r.stationId()&&id(t,"tr_status")!=1))throw bad("Die Station ist nicht offen.");db.update("UPDATE tour SET tr_status=1,tr_finishtime=?,tr_signname=? WHERE tr_id=? AND jb_id=?",now,recipient,r.stationId(),id);}else db.update("UPDATE tour SET tr_status=1,tr_finishtime=?,tr_signname=? WHERE jb_id=? AND COALESCE(tr_status,0)<>1",now,recipient,id);
|
||||
if(db.queryForList("SELECT tr_id FROM tour WHERE jb_id=? AND COALESCE(tr_status,0)<>1 LIMIT 1",id).isEmpty()){changes.putAll(fields("jb_status",2,"jb_finishtime",now,"jb_booktime",now));db.update("DELETE FROM vehicledisposition WHERE jb_id=?",id);command(u,courier,"3",Long.toString(id));}else command(u,courier,"5",Long.toString(id));operation=4;}
|
||||
case "CANCEL","CREDIT"->{String reason=required(r.reason(),500,"Stornogrund");if(id(s.job(),"jb_storno")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",id).isEmpty())throw bad("Für diesen Auftrag besteht bereits ein Storno / eine Gegenbuchung.");if(r.chargeable()&&courier<=0&&offered<=0)throw bad("Kostenpflichtiges Storno benötigt einen Kurier.");if(r.action().equals("CREDIT")&&state!=2)throw bad("Gutschriften setzen einen erledigten Auftrag voraus.");
|
||||
if(id(s.job(),"jb_id_parent")!=0||!db.queryForList("SELECT jb_id FROM job WHERE jb_id_parent=? LIMIT 1",id).isEmpty())throw bad("Verbundene Kurier-Unteraufträge müssen gemeinsam korrigiert werden.");
|
||||
if(!s.payments().isEmpty())throw bad("Es bestehen Einnahmenbuchungen. Diese müssen vor dem Storno fachlich ausgeglichen werden.");
|
||||
resultId=credit(u,id,s,r,reason);changes.put("jb_id_copy",resultId);if(!r.action().equals("CREDIT"))changes.putAll(fields("jb_storno",r.chargeable()?1:2,"usr_id_storno",u.userId(),"jb_status",2,"jb_finishtime",LocalDateTime.now(),"cr_id",courier>0?courier:offered));if(r.endSeries())changes.put("jb_permanent",0);for(long previous:new TreeSet<>(List.of(courier,offered))){command(u,previous,"3",Long.toString(id));command(u,previous,"8",Long.toString(id));}db.update("DELETE FROM vehicledisposition WHERE jb_id=?",id);db.update("DELETE FROM autoranking WHERE jb_id=?",id);operation=10;}
|
||||
default->throw bad("Unbekannte Auftragsaktion.");
|
||||
}
|
||||
changes.put("jb_version",id(s.job(),"jb_version")+1);update(db,"job","jb_id",id,changes);
|
||||
for(long cr:new TreeSet<>(List.of(courier,offered)))if(cr>0)db.update("UPDATE courier SET cr_occupied=(SELECT COUNT(*) FROM job WHERE (cr_id_order=? OR cr_id=?) AND jb_status IN (0,1) AND COALESCE(jb_storno,0)=0) WHERE cr_id=?",cr,cr,cr);
|
||||
support.audit(u,id,0,courier,operation,r.action()+(r.reason()==null?"":" · "+r.reason())+(resultId!=id?" · Gegenbuchung #"+resultId:""),r.requestId());support.receipt(u,r.requestId(),List.of(id,r),resultId);return resultId;});return fields("id",id,"resultId",result,"order",view(u,id));
|
||||
}
|
||||
long credit(LegacyUser u,long id,Snapshot s,Action r,String reason){var copy=new LinkedHashMap<>(s.job());for(String k:List.of("jb_id","jb_modify","jb_export_time","jb_locktime","jb_lockuser","jb_id_copy"))copy.remove(k);copy.putAll(fields("jb_storno",r.chargeable()?3:4,"jb_id_parent",0,"jb_autoranking",0,"jb_globaljob",0,"cr_id",id(s.job(),"cr_id")>0?id(s.job(),"cr_id"):id(s.job(),"cr_id_order"),"jb_status",2,"jb_permanent",0,"jb_reserv",0,"jb_version",0,"jb_id_copy",id,"jb_createtime",LocalDateTime.now(),"jb_finishtime",LocalDateTime.now(),"jb_booktime",LocalDateTime.now(),"emp_id",u.userId(),"jb_invtext","Storno-Gutschrift zu Auftrag "+id+": "+reason,"jb_automailsent",0,"jb_automailsent2",0,"jb_automailsent3",0));
|
||||
for(String k:List.of("jb_fixprice","jb_serviceprice","jb_subtotalprice","jb_totalprice","jb_cr_price","jb_cr_subprice","jb_hq_id_exec_price")){var n=decimal(s.job(),k);if(n!=null)copy.put(k,n.negate());}copy.put("jb_totalprice",customerNet(s).negate());long credit=db.insert("job",copy);
|
||||
OrderPaymentTerms.copy(db,id,credit);
|
||||
for(var t:db.queryForList("SELECT * FROM tour WHERE jb_id=? ORDER BY tr_sort",id)){var c=new LinkedHashMap<>(t);c.remove("tr_id");c.remove("tr_modify");c.put("jb_id",credit);db.insert("tour",c);}
|
||||
for(var p:s.prices()){var c=new LinkedHashMap<>(p);c.remove("trs_modify");c.put("jb_id",credit);c.put("trs_price",decimal(p,"trs_price").negate());insertWithoutKey(db,"tourservice",c);}
|
||||
for(var detail:s.priceDetails()){var c=new LinkedHashMap<>(detail);c.put("jb_id",credit);if(Set.of(2L,3L,4L,7L,10L,11L,12L).contains(id(detail,"mt_sort")))c.put("jbprc_price",decimal(detail,"jbprc_price").negate());insertWithoutKey(db,"jobprice",c);}
|
||||
for(var m:db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",id))db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",credit,"gdc_gen_fieldname","jb_toll","gdc_content",new BigDecimal(text(m,"gdc_content")).negate().toPlainString(),"gdc_context",""));
|
||||
if(r.chargeable()){
|
||||
var tariff=cancellationTariff(u,s);
|
||||
if(s.prices().isEmpty()&&customerNet(s).signum()!=0)insertWithoutKey(db,"tourservice",fields("jb_id",credit,"csc_id",id(s.job(),"csc_id_payer"),"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Gegenbuchung Auftragspreis","trs_srvt_name","","trs_price",customerNet(s).negate(),"trs_discount",0));
|
||||
var centers=new TreeSet<Long>();s.prices().forEach(p->centers.add(id(p,"csc_id")));if(centers.isEmpty())centers.add(id(s.job(),"csc_id_payer"));int index=0;
|
||||
BigDecimal fee=tariff.price();for(long center:centers)insertWithoutKey(db,"tourservice",fields("jb_id",credit,"csc_id",center,"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Stornogebühr","trs_srvt_name","","trs_price",OrderEntryAccounting.portion(fee,centers.size(),index++),"trs_discount",0));
|
||||
// PHP books the Storno tariff price without a discount or an additional courier fee.
|
||||
db.update("UPDATE job SET jb_serviceprice=jb_serviceprice+?,jb_subtotalprice=jb_subtotalprice+?,jb_totalprice=jb_totalprice+? WHERE jb_id=?",fee,fee,fee,credit);
|
||||
}
|
||||
support.audit(u,credit,0,0,1,"Gegenbuchung zu Auftrag #"+id,r.requestId());return credit;
|
||||
}
|
||||
static BigDecimal customerNet(Snapshot s){return s.prices().isEmpty()?decimal(s.job(),"jb_totalprice"):s.prices().stream().map(BillingService::net).reduce(BigDecimal.ZERO,BigDecimal::add);}
|
||||
LegacyOrderPricing.Tariff cancellationTariff(LegacyUser u,Snapshot s){
|
||||
var payer=one(db.queryForList("SELECT cs_id FROM costcenter WHERE csc_id=?",id(s.job(),"csc_id_payer")));
|
||||
var vehicle=one(db.queryForList("SELECT mt_value FROM metatype WHERE mt_type='vehicletype' AND mt_sort=?",id(s.job(),"vht_id")));
|
||||
var tariff=pricing.service(u.headquartersId(),id(payer,"cs_id"),text(vehicle,"mt_value"),"Storno",dateTime(s.job(),"jb_ordertime"),BigDecimal.ZERO);
|
||||
if(!tariff.found()||tariff.price().signum()<=0)throw bad("Kein eindeutiger kostenpflichtiger Stornotarif hinterlegt.");return tariff;
|
||||
}
|
||||
void transactional(){support.transactional("headquarters","genericdatacontainer","job","tour","tourservice","jobprice","jobpayment","address","courier","autoranking","vehicledisposition","phoenix_pda.commandexec");}
|
||||
public Map<String,Object> price(LegacyUser u,long id,Price r){u.require(4);u.require(7);u.require(38);if(r==null)throw bad("Preisangaben fehlen.");token(r.requestId());String reason=required(r.reason(),200,"Begründung");
|
||||
for(BigDecimal amount:List.of(r.customerNet()==null?BigDecimal.valueOf(-1):r.customerNet(),r.courierNet()==null?BigDecimal.valueOf(-1):r.courierNet()))if(amount.signum()<0||amount.scale()>2||amount.compareTo(new BigDecimal("999999.99"))>0)throw bad("Preise müssen zwischen 0 und 999.999,99 € liegen und höchstens zwei Nachkommastellen haben.");
|
||||
transactional();db.writeTransaction(()->{lockHeadquarters(db,u);if(support.receipt(u,r.requestId(),List.of(id,r))!=null)return null;var s=snapshot(u,id,true);unchanged(s,r.revision());writable(s);
|
||||
if(id(s.job(),"jb_id_parent")!=0||id(s.job(),"hq_id_exec")>0&&id(s.job(),"hq_id_exec")!=u.headquartersId()||!s.payments().isEmpty())throw bad("Verrechnete Unteraufträge, Fremdniederlassungs- und Einnahmenbuchungen benötigen eine gemeinsame Korrektur.");
|
||||
var centers=new TreeSet<Long>();s.prices().forEach(p->centers.add(id(p,"csc_id")));if(centers.isEmpty())centers.add(id(s.job(),"csc_id_payer"));
|
||||
db.update("DELETE FROM tourservice WHERE jb_id=?",id);int index=0;for(long center:centers)insertWithoutKey(db,"tourservice",fields("jb_id",id,"csc_id",center,"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis (manuell)","trs_srvt_name","","trs_price",OrderEntryAccounting.portion(r.customerNet(),centers.size(),index++),"trs_discount",0));
|
||||
db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id);for(int mode:List.of(2,8,9,10))insertWithoutKey(db,"jobprice",fields("jb_id",id,"mt_sort",mode,"jbprc_price",mode==2||mode==10?r.courierNet():BigDecimal.ZERO,"jbprc_remark",""));
|
||||
update(db,"job","jb_id",id,fields("jb_fixprice",r.customerNet(),"jb_serviceprice",0,"jb_subtotalprice",r.customerNet(),"jb_totalprice",r.customerNet(),"jb_markup",0,"jb_cr_price",r.courierNet(),"jb_cr_subprice",r.courierNet(),"jb_incomplete",0,"jb_version",id(s.job(),"jb_version")+1,"jb_invtext",text(s.job(),"jb_invtext")+"\nPreisberichtigung: "+reason+" · Netto "+r.customerNet()+" EUR","jb_invtext_cr",text(s.job(),"jb_invtext_cr")+"\nPreisberichtigung: "+reason+" · Frachtvergütung "+r.courierNet()+" EUR"));
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_cost_split'",id);
|
||||
if(id(s.job(),"jb_status")==1)command(u,id(s.job(),"cr_id"),"5",Long.toString(id));db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_calculation_input','ng_calculation_result')",id);support.audit(u,id,0,0,2,"Manueller Fixpreis · "+reason,r.requestId());support.receipt(u,r.requestId(),List.of(id,r),id);return null;});return view(u,id);
|
||||
}
|
||||
void command(LegacyUser u,long courier,String command,String value){if(courier<=0)return;db.insert("`phoenix_pda`.commandexec",fields("cr_id",courier,"hq_id",u.headquartersId(),"cmd_command",command,"cmde_mode","1","cmde_param",value,"cmde_exec_time",LocalDateTime.now(),"cmde_exec_timelimit",LocalDateTime.now().plusHours(36)));}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** A separate snapshot avoids PHP's ambiguous shared jobprice type 16. */
|
||||
final class OrderPaymentTerms {
|
||||
private OrderPaymentTerms() {}
|
||||
static void capture(DatabaseService db,long order,long customer) {
|
||||
var rows=db.queryForList("SELECT cs.cs_payment_term,mt.mt_value,mt.mt_mapped_value FROM customer cs LEFT JOIN metatype mt ON mt.mt_type='payment_term' AND mt.mt_sort=cs.cs_payment_term WHERE cs.cs_id=?",customer);
|
||||
if(rows.size()!=1)throw AdminSupport.bad("Die Zahlungsbedingung des Kunden ist nicht eindeutig.");
|
||||
var value=rows.getFirst();
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",order,"gdc_gen_fieldname","ng_payment_term","gdc_content",text(value,"mt_value"),"gdc_context",Long.toString(id(value,"cs_payment_term"))));
|
||||
if(!text(value,"mt_mapped_value").isBlank())db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",order,"gdc_gen_fieldname","ng_payment_term_label","gdc_content",text(value,"mt_mapped_value"),"gdc_context",Long.toString(id(value,"cs_payment_term"))));
|
||||
}
|
||||
static Map<String,Object> read(DatabaseService db,long order) {
|
||||
var rows=db.queryForList("SELECT gdc_content AS label,gdc_context AS code FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_payment_term' ORDER BY gdc_id",order);
|
||||
if(rows.size()>1)throw AdminSupport.bad("Auftrag #"+order+": Zahlungsbedingung ist mehrfach gespeichert.");
|
||||
if(!rows.isEmpty()) {
|
||||
var labels=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_payment_term_label'",order);
|
||||
if(labels.size()>1)throw AdminSupport.bad("Auftrag #"+order+": Zahlungsbedingung ist mehrfach beschriftet.");
|
||||
return fields("code",text(rows.getFirst(),"code"),"value",text(rows.getFirst(),"label"),"label",labels.isEmpty()?text(rows.getFirst(),"label"):text(labels.getFirst(),"gdc_content"),"source","Auftragsstand");
|
||||
}
|
||||
// A kilometer-price row has no remark. Never reinterpret its amount as a payment-term code.
|
||||
var legacy=db.queryForList("SELECT jbprc_price AS code,jbprc_remark AS label FROM jobprice WHERE jb_id=? AND mt_sort=16 AND TRIM(COALESCE(jbprc_remark,''))<>''",order);
|
||||
if(legacy.size()>1)throw AdminSupport.bad("Auftrag #"+order+": Mehrdeutige PHP-Zahlungsbedingung (Preistyp 16).");
|
||||
return legacy.isEmpty()?fields("code","","label","","source","Nicht im Auftrag gespeichert"):fields("code",text(legacy.getFirst(),"code"),"label",text(legacy.getFirst(),"label"),"source","PHP-Auftragsstand");
|
||||
}
|
||||
static void copy(DatabaseService db,long from,long to) {
|
||||
for(var row:db.queryForList("SELECT gdc_content,gdc_context,gdc_gen_fieldname FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname IN ('ng_payment_term','ng_payment_term_label')",from))
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",to,"gdc_gen_fieldname",text(row,"gdc_gen_fieldname").isBlank()?"ng_payment_term":text(row,"gdc_gen_fieldname"),"gdc_content",text(row,"gdc_content"),"gdc_context",text(row,"gdc_context")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.OrderIntegrationClient.unavailable;
|
||||
|
||||
@Service
|
||||
public class OrderPhotoWorkflows {
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final OrderIntegrationClient client;
|
||||
private final ObjectMapper json;
|
||||
record Selection(long jbId,long trId,int number) {}
|
||||
record SetSelection(long jbId,int setNumber) {}
|
||||
record Transfer(List<Selection> selections,int status,long courierId,long headquartersId,Integer setNumber) {
|
||||
Transfer(List<Selection> selections,int status,long courierId,long headquartersId){this(selections,status,courierId,headquartersId,null);}
|
||||
}
|
||||
public OrderPhotoWorkflows(DatabaseService db,OrderEntryCatalog catalog,OrderIntegrationClient client,ObjectMapper json) {
|
||||
this.db=db;this.catalog=catalog;this.client=client;this.json=json;
|
||||
}
|
||||
private String url() {return catalog.parameter(0,"URL_CS_PHOTO_WORKFLOW").trim().replaceAll("/+$","");}
|
||||
public PhotoOptions options(LegacyUser user,long costCenter) {
|
||||
user.require(7);
|
||||
return options(id(catalog.costCenter(user,costCenter,false),"cs_id"));
|
||||
}
|
||||
PhotoOptions options(long customer) {
|
||||
String url=url();
|
||||
if(url.isBlank())return new PhotoOptions(false,List.of());
|
||||
List<PhotoSet> sets=readSets(url,customer);
|
||||
if(sets.isEmpty() && customer!=0)sets=readSets(url,0);
|
||||
return new PhotoOptions(true,sets);
|
||||
}
|
||||
private List<PhotoSet> readSets(String url,long customer) {
|
||||
var items=result(client.get(url+"/sets/kunde/"+customer,"Der Fotodienst"));
|
||||
List<PhotoSet> sets=new ArrayList<>();Set<Integer> numbers=new HashSet<>();
|
||||
for(var item:items) {
|
||||
if(!allowed(item))continue;
|
||||
int set=number(item,false);if(!numbers.add(set))throw unavailable("Der Fotodienst hat doppelte Set-Nummern geliefert.");
|
||||
if(!item.path("workflows").isArray())throw unavailable("Der Fotodienst hat ein ungültiges Foto-Set geliefert.");
|
||||
var flows=new ArrayList<PhotoWorkflow>();var flowNumbers=new HashSet<Integer>();
|
||||
for(var flow:item.path("workflows")) {
|
||||
if(!allowed(flow))continue;
|
||||
int no=number(flow,true);if(!flowNumbers.add(no))throw unavailable("Der Fotodienst hat doppelte Workflow-Nummern geliefert.");
|
||||
int min=quantity(flow,"min"),max=quantity(flow,"max");if(max<min)throw unavailable("Der Fotodienst hat ungültige Fotomengen geliefert.");
|
||||
String scope=flow.path("stationScope").asText("ALL");
|
||||
if(!Set.of("ALL","PICKUP_ONLY","DELIVERY_ONLY","EXCEPT_PICKUP","EXCEPT_DELIVERY").contains(scope))throw unavailable("Der Fotodienst hat einen unbekannten Stationsbereich geliefert.");
|
||||
var positions=new ArrayList<String>();if(flow.hasNonNull("positions")) {
|
||||
if(!flow.path("positions").isArray())throw unavailable("Der Fotodienst hat ungültige Stationspositionen geliefert.");
|
||||
for(var position:flow.path("positions")){if(!Set.of("FIRST","MIDDLE","LAST").contains(position.asText()))throw unavailable("Der Fotodienst hat eine unbekannte Stationsposition geliefert.");positions.add(position.asText());}
|
||||
}
|
||||
flows.add(new PhotoWorkflow(no,flow.path("name").asText(),flow.path("default").asBoolean(),min,max,scope,List.copyOf(positions)));
|
||||
}
|
||||
sets.add(new PhotoSet(set,item.path("name").asText(),item.path("default").asBoolean(),List.copyOf(flows)));
|
||||
}
|
||||
if(sets.stream().filter(PhotoSet::defaultSet).count()>1)throw unavailable("Der Fotodienst hat mehrere Standardsets geliefert.");
|
||||
return List.copyOf(sets);
|
||||
}
|
||||
private static boolean allowed(JsonNode node){return !node.hasNonNull("allowed") || node.path("allowed").isBoolean() && node.path("allowed").asBoolean();}
|
||||
private static int number(JsonNode node,boolean zero){
|
||||
if(!node.path("number").asText().matches("[0-9]{1,9}") || node.path("name").asText().isBlank() || node.path("name").asText().length()>500)throw unavailable("Der Fotodienst hat eine ungültige Auswahl geliefert.");
|
||||
int value=node.path("number").asInt();if(!zero && value==0)throw unavailable("Der Fotodienst hat eine ungültige Set-Nummer geliefert.");return value;
|
||||
}
|
||||
private static int quantity(JsonNode node,String key){if(!node.hasNonNull(key))return 0;if(!node.path(key).asText().matches("[0-9]{1,6}"))throw unavailable("Der Fotodienst hat eine ungültige Fotomenge geliefert.");return node.path(key).asInt();}
|
||||
// PHP photoScopeMatches uses ware=1 for PICKUP_ONLY and ware=2 for DELIVERY_ONLY.
|
||||
// Preserve these legacy flags (our tour encoder writes DELIVERY=1, PICKUP=2).
|
||||
static boolean offered(PhotoWorkflow flow,Models.StopType type){
|
||||
if(type==Models.StopType.BOTH)return offered(flow,Models.StopType.PICKUP)||offered(flow,Models.StopType.DELIVERY);
|
||||
return switch(flow.stationScope()){case "PICKUP_ONLY","EXCEPT_DELIVERY"->type==Models.StopType.DELIVERY;case "DELIVERY_ONLY","EXCEPT_PICKUP"->type==Models.StopType.PICKUP;default->true;};
|
||||
}
|
||||
PhotoPlan resolve(Request request,long customer) {
|
||||
var options=options(customer);PhotoSet set;
|
||||
if(request.photoSet()!=null)set=options.sets().stream().filter(v->v.number()==request.photoSet()).findFirst().orElseThrow(()->invalid("Das Foto-Set ist für diesen Kunden nicht mehr freigegeben. Bitte neu laden."));
|
||||
else set=options.sets().stream().filter(PhotoSet::defaultSet).findFirst().orElse(options.sets().isEmpty()?null:options.sets().getFirst());
|
||||
List<PhotoChoice> choices=new ArrayList<>();
|
||||
for(int i=0;i<request.stops().size();i++) {
|
||||
var station=request.stops().get(i);Integer selected=station.photoWorkflow();
|
||||
List<PhotoWorkflow> offered=set==null?List.of():set.workflows().stream().filter(f->offered(f,station.type())).toList();
|
||||
String position=i==request.stops().size()-1?"LAST":i==0?"FIRST":"MIDDLE";
|
||||
var flow=selected==null?offered.stream().filter(f->f.positions().contains(position)).findFirst().orElseGet(()->offered.stream().filter(PhotoWorkflow::defaultWorkflow).findFirst().orElse(null)):
|
||||
offered.stream().filter(f->f.number()==selected).findFirst().orElseThrow(()->invalid("Die Fotoauswahl passt nicht mehr zum Set oder zur Station. Bitte neu auswählen."));
|
||||
if(flow!=null)choices.add(new PhotoChoice(i+1,flow.number(),flow.name()));
|
||||
}
|
||||
return new PhotoPlan(set,List.copyOf(choices));
|
||||
}
|
||||
void enqueue(long job,List<Selection> selections,int desiredStatus,long courierId,long hq,Integer setNumber) {
|
||||
if(selections.isEmpty() && setNumber==null)return;
|
||||
try {
|
||||
String content=json.writeValueAsString(new Transfer(List.copyOf(selections),desiredStatus,courierId,hq,setNumber));
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",job,"gdc_gen_fieldname","ng_photo_selection",
|
||||
"gdc_content",content,"gdc_context","PENDING"));
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Fotoauswahl konnte nicht gesichert werden.",e);}
|
||||
}
|
||||
record Claim(long id,long job,String context,Transfer transfer) {}
|
||||
private static String leasePrefix(long seconds) {return "SENDING:"+String.format(Locale.ROOT,"%012d",seconds)+":";}
|
||||
private static String newLease() {return leasePrefix(java.time.Instant.now().plusSeconds(45).getEpochSecond())+UUID.randomUUID();}
|
||||
private static String expiredBefore() {return leasePrefix(java.time.Instant.now().getEpochSecond());}
|
||||
/** Short DB leases prevent parallel publishers without holding a job lock across HTTP calls. */
|
||||
private Claim claim(long job) {
|
||||
return db.writeTransaction(() -> {
|
||||
if(db.lockRows("SELECT jb_id FROM job WHERE jb_id=?",job).isEmpty())return null;
|
||||
var rows=db.queryForList("SELECT gdc_id,gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_photo_selection' AND (gdc_context='PENDING' OR (gdc_context LIKE 'SENDING:%' AND gdc_context<?)) ORDER BY gdc_id LIMIT 1",job,expiredBefore());
|
||||
if(rows.isEmpty())return null;
|
||||
var row=rows.getFirst();Transfer transfer;
|
||||
try {transfer=json.readValue(text(row,"gdc_content"),Transfer.class);}
|
||||
catch(com.fasterxml.jackson.core.JsonProcessingException e) {throw new IllegalStateException("Ungültiger Fotoauftrag.",e);}
|
||||
String lease=newLease();long id=id(row,"gdc_id");
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=?",lease,id);
|
||||
return new Claim(id,job,lease,transfer);
|
||||
});
|
||||
}
|
||||
private Claim renew(Claim claim) {
|
||||
String lease=newLease();
|
||||
int changed=db.writeTransaction(() -> db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_id=? AND gdc_context=?",lease,claim.id(),claim.context()));
|
||||
if(changed!=1)throw new IllegalStateException("Fotoübertragung wird bereits von einem anderen Prozess bearbeitet.");
|
||||
return new Claim(claim.id(),claim.job(),lease,claim.transfer());
|
||||
}
|
||||
/** A lost acknowledgement is recovered using GET /selection before retrying POST. */
|
||||
void publish(long job) {
|
||||
Claim claim=claim(job);if(claim==null)return;
|
||||
try {
|
||||
String url=url();if(url.isBlank())throw unavailable("Der Fotodienst ist nicht konfiguriert.");
|
||||
// Reapply the same set on retry, then read station selections AFTER that assignment.
|
||||
// This also restores station overrides if assigning the set resets them remotely.
|
||||
if(claim.transfer().setNumber()!=null){claim=renew(claim);client.post(url+"/selection/set",new SetSelection(job,claim.transfer().setNumber()),"Der Fotodienst");}
|
||||
var existing=result(client.get(url+"/selection?jbId="+job,"Der Fotodienst"));
|
||||
for(var selection:claim.transfer().selections()) {
|
||||
boolean found=false;
|
||||
for(var item:existing)if(item.path("tr_id").asLong()==selection.trId() && item.path("number").asInt(-1)==selection.number())found=true;
|
||||
if(!found) {
|
||||
claim=renew(claim);
|
||||
client.post(url+"/selection",selection,"Der Fotodienst");
|
||||
}
|
||||
}
|
||||
finish(claim);
|
||||
}catch(RuntimeException failure) {
|
||||
Claim failed=claim;
|
||||
try {db.writeTransaction(() -> db.update("UPDATE genericdatacontainer SET gdc_context='PENDING' WHERE gdc_id=? AND gdc_context=?",failed.id(),failed.context()));}
|
||||
catch(RuntimeException ignored) { /* The lease expires after a crash or database outage. */ }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
private void finish(Claim claim) {
|
||||
db.writeTransaction(() -> {
|
||||
var jobs=db.lockRows("SELECT jb_id,jb_status,jb_version,jb_storno FROM job WHERE jb_id=?",claim.job());
|
||||
if(jobs.isEmpty())return null;
|
||||
int changed=db.update("UPDATE genericdatacontainer SET gdc_context='SENT' WHERE gdc_id=? AND gdc_context=?",claim.id(),claim.context());
|
||||
if(changed!=1)return null;
|
||||
Transfer transfer=claim.transfer();
|
||||
// Never undo an operator's subsequent disposition, cancellation or completion.
|
||||
if(id(jobs.getFirst(),"jb_status")==9 && id(jobs.getFirst(),"jb_version")==0 && id(jobs.getFirst(),"jb_storno")==0 && transfer.status()!=9) {
|
||||
db.update("UPDATE job SET jb_status=? WHERE jb_id=? AND jb_status=9 AND jb_version=0 AND COALESCE(jb_storno,0)=0",transfer.status(),claim.job());
|
||||
if(transfer.status()==0 && transfer.courierId()>0)db.update("UPDATE courier SET cr_occupied=1 WHERE cr_id=? AND hq_id=?",transfer.courierId(),transfer.headquartersId());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
boolean pending(long job) {
|
||||
return !db.queryForList("SELECT gdc_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_photo_selection' AND gdc_context<>'SENT'",job).isEmpty();
|
||||
}
|
||||
void tryPublish(long job) {
|
||||
try {publish(job);}catch(RuntimeException e) {org.slf4j.LoggerFactory.getLogger(getClass()).warn("Eine gesicherte Fotoauswahl wird später erneut übertragen; der Auftrag bleibt zur Prüfung in der Disposition.");}
|
||||
}
|
||||
@Scheduled(initialDelay=60000,fixedDelay=30000)
|
||||
public void retryPending() {
|
||||
try {
|
||||
var rows=db.queryForList("SELECT gdc_obj_id FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_gen_fieldname='ng_photo_selection' AND (gdc_context='PENDING' OR (gdc_context LIKE 'SENDING:%' AND gdc_context<?)) ORDER BY gdc_id LIMIT 20",expiredBefore());
|
||||
for(var row:rows)tryPublish(id(row,"gdc_obj_id"));
|
||||
}catch(RuntimeException ignored) { /* Durable entries remain pending during a DB outage. */ }
|
||||
}
|
||||
static JsonNode result(JsonNode json) {
|
||||
if(json==null || !json.path("result").isArray())throw unavailable("Der Fotodienst hat ungültige Daten geliefert.");
|
||||
return json.path("result");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.Validator;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.BusinessSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.LegacyOrderPricing.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Reprices the saved route without replaying creation, stock movements or driver state. */
|
||||
@RestController
|
||||
@RequestMapping("/api/orders/{id}/calculation")
|
||||
public class OrderRecalculation {
|
||||
final DatabaseService db;
|
||||
final BusinessSupport support;
|
||||
final OrderOperations orders;
|
||||
final OrderEntryCatalog catalog;
|
||||
final LegacyOrderPricing pricing;
|
||||
final OrderRoutingService routing;
|
||||
final Validator validator;
|
||||
public OrderRecalculation(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderEntryCatalog catalog,LegacyOrderPricing pricing,OrderRoutingService routing,Validator validator) {
|
||||
this.db=db;this.support=support;this.orders=orders;this.catalog=catalog;this.pricing=pricing;this.routing=routing;this.validator=validator;
|
||||
}
|
||||
public record Input(@NotNull @Valid Pricing pricing,@Min(0) int vehicleTypeId,
|
||||
@Size(max=100) List<@Valid ServiceItem> services,boolean costSplit,boolean confirmCourierLimit,
|
||||
@Size(max=20) List<@NotNull @Valid CourierShare> courierShares,String childrenRevision,
|
||||
@Size(max=20) List<@NotNull @Min(0) Long> pairIds) {
|
||||
public Input { services=services==null?List.of():List.copyOf(services);courierShares=courierShares==null?List.of():List.copyOf(courierShares); }
|
||||
public Input(Pricing pricing,int vehicleTypeId,List<ServiceItem> services,boolean costSplit,boolean confirmCourierLimit){this(pricing,vehicleTypeId,services,costSplit,confirmCourierLimit,List.of(),null,null);}
|
||||
public Input(Pricing pricing,int vehicleTypeId,List<ServiceItem> services,boolean costSplit,boolean confirmCourierLimit,List<CourierShare> shares,String revision){this(pricing,vehicleTypeId,services,costSplit,confirmCourierLimit,shares,revision,null);}
|
||||
}
|
||||
public record Command(String requestId,String revision,@NotNull @Valid Input input,String fingerprint) {}
|
||||
record Context(OrderOperations.Snapshot snapshot,Request request,Map<String,Object> payer,List<OrderChildRecalculation.Pair> children) {}
|
||||
|
||||
@GetMapping public Object get(Authentication auth,@PathVariable long id) {return options(ApiController.user(auth),id);}
|
||||
@PostMapping public Object preview(Authentication auth,@PathVariable long id,@Valid @RequestBody Command command) {return preview(ApiController.user(auth),id,command);}
|
||||
@PutMapping public Object save(Authentication auth,@PathVariable long id,@Valid @RequestBody Command command) {return save(ApiController.user(auth),id,command);}
|
||||
|
||||
Map<String,Object> options(LegacyUser user,long id) {
|
||||
user.require(4);user.require(7);
|
||||
return db.readTransaction(()->{
|
||||
var s=orders.snapshot(user,id,false);
|
||||
var options=catalog.options(user);
|
||||
long tax=options.taxes().stream().filter(t->t.label().equals(text(s.job(),"jb_sales_tax_rate_sign"))).mapToLong(Tax::id).findFirst().orElse(0);
|
||||
PriceMode mode=s.prices().stream().anyMatch(p->text(p,"trs_srvt_name").equals("z"))?PriceMode.ZONE:positive(decimal(s.job(),"jb_fixprice"))?PriceMode.FIXED:PriceMode.TARIFF;
|
||||
var previous=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_calculation_input'",id);
|
||||
var services=new OrderEntryTemplates(db,catalog).services(user,payerId(s),text(s.job(),"jb_freetext_2"));
|
||||
if(!services.isEmpty())mode=PriceMode.SERVICES;
|
||||
Input input=new Input(new Pricing(mode,decimal(s.job(),"jb_fixprice"),decimal(s.job(),"jb_serviceprice"),detail(s,2),detail(s,3),detail(s,8),detail(s,9),s.prices().stream().anyMatch(p->positive(decimal(p,"trs_discount"))),tax),(int)id(s.job(),"vht_id"),services,id(s.job(),"jb_payment")==1,false);
|
||||
if(previous.size()==1&&text(previous.getFirst(),"gdc_context").equals(Long.toString(id(s.job(),"jb_version"))))try {input=support.json.readValue(text(previous.getFirst(),"gdc_content"),Input.class);}catch(Exception e){throw bad("Der gespeicherte Kalkulationsstand ist nicht lesbar.");}
|
||||
if(!user.hasRight(38)){var p=input.pricing();input=new Input(new Pricing(p.mode(),p.fixedPrice(),p.servicePrice(),null,null,p.markup(),null,p.applyFixedDiscount(),p.taxId()),input.vehicleTypeId(),input.services(),input.costSplit(),false);}
|
||||
var children=new OrderChildRecalculation(db,orders,support);var pairs=children.load(user,s,false);
|
||||
var currentPricing=input.pricing();input=new Input(pairs.isEmpty()?currentPricing:new Pricing(currentPricing.mode(),currentPricing.fixedPrice(),currentPricing.servicePrice(),null,null,currentPricing.markup(),null,currentPricing.applyFixedDiscount(),currentPricing.taxId()),input.vehicleTypeId(),input.services(),input.costSplit(),false,children.shares(user,pairs),revision(pairs),pairs.stream().map(pair->id(pair.positive().job(),"jb_id")).toList());
|
||||
return fields("children",pairs.stream().map(p->fields("positive",id(p.positive().job(),"jb_id"),"negative",id(p.negative().job(),"jb_id"),"sid",text(p.positive().job(),"cr_sid"))).toList(),"revision",revision(s),"input",input,"options",options,"services",catalog.services(user,payerId(s)),"paymentTerm",OrderPaymentTerms.read(db,id));
|
||||
});
|
||||
}
|
||||
static BigDecimal detail(OrderOperations.Snapshot s,int kind) {var rows=s.priceDetails().stream().filter(p->id(p,"mt_sort")==kind).toList();return rows.size()==1?decimal(rows.getFirst(),"jbprc_price"):null;}
|
||||
static long payerId(OrderOperations.Snapshot s) {return id(s.job(),"csc_id_payer_cash")>0?id(s.job(),"csc_id_payer_cash"):id(s.job(),"csc_id_payer");}
|
||||
Context prepare(LegacyUser user,long id,Command command,boolean lock) {
|
||||
user.require(4);user.require(7);
|
||||
if(command==null||command.input()==null||!validator.validate(command).isEmpty())throw bad("Bitte die Kalkulationswerte prüfen.");
|
||||
var s=orders.snapshot(user,id,lock);unchanged(s,command.revision());orders.writable(s);
|
||||
var children=new OrderChildRecalculation(db,orders,support);var pairs=children.load(user,s,lock);children.validate(user,s,pairs,command.input());
|
||||
if(!s.payments().isEmpty())throw bad("Vor der Neukalkulation müssen vorhandene Einnahmenbuchungen ausgeglichen werden.");
|
||||
if(command.input().pricing().mode()!=PriceMode.SERVICES&&!command.input().services().isEmpty())throw bad("Für Leistungspositionen bitte Leistungskalkulation wählen.");
|
||||
if(command.input().pricing().courierPrice()!=null||command.input().pricing().courierServicePrice()!=null||command.input().pricing().courierMarkup()!=null)user.require(38);
|
||||
var payer=catalog.costCenter(user,payerId(s),id(s.job(),"jb_payment")!=2);
|
||||
var job=s.job();var stops=new ArrayList<Station>();
|
||||
for(var t:s.stops())stops.add(new Station(nullableId(t,"csc_id"),id(t,"tr_ware_from_to")==3?Models.StopType.BOTH:id(t,"tr_ware_from_to")==1?Models.StopType.DELIVERY:Models.StopType.PICKUP,text(t,"tr_comp"),text(t,"ad_street"),text(t,"tr_hsno"),text(t,"ad_zipcode"),text(t,"ad_city"),text(t,"ad_country").equals("D")?"DE":text(t,"ad_country"),text(t,"tr_person"),text(t,"tr_phone"),text(t,"tr_remark"),text(t,"tr_commission_no"),null,nullableId(t,"tr_mediationarea_id"),List.of()));
|
||||
if(stops.isEmpty())throw bad("Der Auftrag besitzt keine Stationen.");
|
||||
var vehicles=db.queryForList("SELECT crvh_id FROM couriervehicle WHERE cr_id=? AND crvh_sid=?",id(job,"cr_id"),text(job,"cr_sid"));
|
||||
var toll=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",id);
|
||||
if(toll.size()>1)throw bad("Mehrdeutige Mautposition.");
|
||||
var data=fields("mode",id(job,"jb_status")==2?"RETROSPECTIVE":"IMMEDIATE","pickupAt",dateTime(job,"jb_ordertime"),"payerCostCenterId",payerId(s),"payment",id(job,"jb_payment")==2?"CASH":"INVOICE","cashStop",id(job,"jb_payment")==2?1:null,
|
||||
"vehicleTypeId",command.input().vehicleTypeId(),"courierVehicleId",vehicles.size()==1?id(vehicles.getFirst(),"crvh_id"):null,"stops",stops,"orderer",text(job,"jb_orderer"),"reference",text(job,"jb_commission_no"),"weight",decimal(job,"jb_weight"),"longhaul",id(job,"jb_longhaul")==1?"AUTO":"ASAP",
|
||||
"pricing",command.input().pricing(),"requestId",UUID.randomUUID().toString(),"services",command.input().services(),"copies",1,"courierShares",command.input().courierShares(),"costSplit",command.input().costSplit(),"toll",toll.isEmpty()?BigDecimal.ZERO:new BigDecimal(text(toll.getFirst(),"gdc_content")));
|
||||
return new Context(s,support.json.convertValue(data,Request.class),payer,pairs);
|
||||
}
|
||||
Quote calculate(LegacyUser user,Context context,OrderRoutingService.Result route) {
|
||||
var q=pricing.calculate(user,context.request(),context.payer(),dateTime(context.snapshot().job(),"jb_ordertime"),route,List.of());
|
||||
return new OrderEntryAccounting(db,catalog).quote(user,context.request(),q,List.of());
|
||||
}
|
||||
Map<String,Object> preview(LegacyUser user,long id,Command command) {
|
||||
var context=db.readTransaction(()->prepare(user,id,command,false));
|
||||
var route=routing.resolve(user,context.request(),id(context.snapshot().job(),"hq_id_dispo"));
|
||||
return db.readTransaction(()->{var current=prepare(user,id,command,false);var q=calculate(user,current,route);return fields("quote",q,"fingerprint",fingerprint(command,q),"pairChanges",new OrderChildRecalculation(db,orders,support).changes(current.children(),command.input()),"previousNet",decimal(current.snapshot().job(),"jb_totalprice"),"previousCourierNet",decimal(current.snapshot().job(),"jb_cr_price"));});
|
||||
}
|
||||
static String fingerprint(Command c,Quote q) {return revision(List.of(c.revision(),c.input(),q));}
|
||||
Map<String,Object> save(LegacyUser user,long id,Command command) {
|
||||
user.require(4);user.require(7);if(command==null)throw bad("Kalkulationsdaten fehlen.");token(command.requestId());
|
||||
if(db.readTransaction(()->support.receipt(user,command.requestId(),List.of(id,command)))!=null)return orders.view(user,id);
|
||||
var context=db.readTransaction(()->prepare(user,id,command,false));
|
||||
var route=routing.resolve(user,context.request(),id(context.snapshot().job(),"hq_id_dispo"));
|
||||
orders.transactional();
|
||||
db.writeTransaction(()->{
|
||||
lockHeadquarters(db,user);if(support.receipt(user,command.requestId(),List.of(id,command))!=null)return null;
|
||||
var current=prepare(user,id,command,true);var q=calculate(user,current,route);
|
||||
if(!fingerprint(command,q).equals(command.fingerprint()))throw bad("Die Preisvorschau hat sich geändert. Bitte neu berechnen.");
|
||||
if(q.courierLimitExceeded()){user.require(39);if(!command.input().confirmCourierLimit())throw bad("Die Überschreitung der Frachtvergütungsgrenze muss bestätigt werden.");}
|
||||
write(user,id,current,q);
|
||||
new OrderChildRecalculation(db,orders,support).write(user,current.snapshot(),current.children(),command.input(),q,command.requestId());
|
||||
metadata(id,"ng_calculation_input",support.encode(command.input()));
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_calculation_input'",Long.toString(id(current.snapshot().job(),"jb_version")+1),id);
|
||||
metadata(id,"ng_calculation_result",support.encode(q));
|
||||
if(q.route()!=null)metadata(id,"ng_route_calculation",support.encode(q.route()));
|
||||
support.audit(user,id,id(current.payer(),"cs_id"),id(current.snapshot().job(),"cr_id"),2,"Preis neu berechnet · "+command.input().pricing().mode()+" · Netto "+q.net()+" · Frachtvergütung "+q.courierNet(),command.requestId());
|
||||
support.receipt(user,command.requestId(),List.of(id,command),id);return null;
|
||||
});return orders.view(user,id);
|
||||
}
|
||||
void metadata(long id,String key,String value) {
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",id,key);
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context","RECALCULATE"));
|
||||
}
|
||||
void write(LegacyUser user,long id,Context context,Quote q) {
|
||||
Request r=context.request();var job=context.snapshot().job();long payer=id(job,"csc_id_payer");
|
||||
db.update("DELETE FROM tourservice WHERE jb_id=?",id);
|
||||
int skip=r.pricing().mode()==PriceMode.SERVICES?r.services().size():0;
|
||||
if(!q.batch()&&skip>0)component(id,payer,0,"Fixpreis","",q.components().stream().limit(skip).map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add),BigDecimal.ZERO);
|
||||
if(!q.batch())for(var c:q.components().stream().skip(skip).toList())component(id,payer,c.sequence(),c.name(),c.type(),c.price(),c.discount());
|
||||
if(r.pricing().mode()==PriceMode.ZONE&&!q.batch()){int n=0;for(var c:q.calculation())component(id,payer,++n,c.quantity().toPlainString()+" × "+c.name()+" à "+c.rate(),"z",BigDecimal.ZERO,BigDecimal.ZERO);}
|
||||
db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id);
|
||||
price(id,r.pricing().courierMarkup()==null?1:9,q.courierMarkup());price(id,2,q.courierBase());price(id,3,q.courierService());price(id,5,q.courierDeduction());price(id,10,q.courierSubtotal());
|
||||
if(r.pricing().markup()!=null)price(id,8,q.markup());if(positive(q.courierAgio()))price(id,7,q.courierAgio());
|
||||
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_cost_split'",id);
|
||||
new OrderEntryAccounting(db,catalog).split(id,q);
|
||||
BigDecimal fixed=q.batch()?BigDecimal.ZERO:q.calculatedBase()!=null?q.calculatedBase():r.pricing().mode()==PriceMode.FIXED?r.pricing().fixedPrice():skip>0?q.components().stream().limit(skip).map(Component::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add):BigDecimal.ZERO;
|
||||
var update=fields("vht_id",r.vehicleTypeId(),"jb_payment",r.payment()==Payment.CASH?2:r.costSplit()?1:0,"jb_fixprice",fixed,"jb_serviceprice",q.components().stream().filter(c->c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add),"jb_totalprice",q.net(),"jb_subtotalprice",q.subtotal(),"jb_cr_price",q.courierNet(),"jb_cr_subprice",q.courierBase(),"jb_markup",q.markup(),"jb_invmode",q.invoiceMode(),"jb_sales_tax_rate",q.taxRate(),"jb_sales_tax_rate_sign",q.taxSign(),"jb_version",id(job,"jb_version")+1,"jb_freetext_2",skip>0?new OrderEntryExtras(db,catalog).calculatorText(user,r,q):"");
|
||||
// Repricing does not silently confirm unrelated missing references, emissions or customer checks.
|
||||
if(q.batch())update.put("jb_incomplete",1);
|
||||
BusinessSupport.update(db,"job","jb_id",id,update);
|
||||
metadata(id,"jb_invtext_dirty","1");
|
||||
if(id(job,"jb_status")==1)orders.command(user,id(job,"cr_id"),"5",Long.toString(id));
|
||||
}
|
||||
void component(long id,long payer,int sequence,String name,String type,BigDecimal price,BigDecimal discount) {
|
||||
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,?,0,?,?,?)",id,payer,sequence,name.substring(0,Math.min(50,name.length())),type.substring(0,Math.min(50,type.length())),price,discount);
|
||||
}
|
||||
void price(long id,int type,BigDecimal value) {db.update("INSERT INTO jobprice (jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,?,?,'')",id,type,value);}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.*;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.invalid;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.number;
|
||||
import static de.stadtbote.votian.OrderIntegrationClient.unavailable;
|
||||
|
||||
/** Unsaved-tour counterpart of get_coords, get_zone_distance and foreign ROUND_KMS selection. */
|
||||
@Service
|
||||
public class OrderRoutingService {
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final OrderIntegrationClient client;
|
||||
private final String pzmUrl,fallbackUrl,configuredKey,geocodeUrl;
|
||||
private final Map<String,Cached> cache=Collections.synchronizedMap(new LinkedHashMap<>());
|
||||
record Cached(Instant at,JsonNode value) {}
|
||||
record Point(double latitude,double longitude) {
|
||||
Point {if(!Double.isFinite(latitude)||!Double.isFinite(longitude)||Math.abs(latitude)>90||Math.abs(longitude)>180)throw unavailable("Ungültige Geokoordinaten.");}
|
||||
String station(){return latitude+","+longitude;}
|
||||
}
|
||||
record Result(RouteInfo info,JsonNode zones) {}
|
||||
public OrderRoutingService(DatabaseService db,OrderEntryCatalog catalog,OrderIntegrationClient client,
|
||||
@Value("${app.routing.pzm-url:}") String pzmUrl,@Value("${app.routing.fallback-url:}") String fallbackUrl,
|
||||
@Value("${app.routing.google-api-key:}") String apiKey,
|
||||
@Value("${app.routing.geocode-url:https://maps.googleapis.com/maps/api/geocode/json}") String geocodeUrl) {
|
||||
this.db=db;this.catalog=catalog;this.client=client;this.pzmUrl=pzmUrl;this.fallbackUrl=fallbackUrl;this.configuredKey=apiKey;this.geocodeUrl=geocodeUrl;
|
||||
}
|
||||
Result resolve(LegacyUser user,Request request,long domesticHq) {
|
||||
boolean foreign=!request.stops().getFirst().country().equals("DE");
|
||||
boolean zone=request.pricing().mode()==PriceMode.ZONE;
|
||||
boolean automatic=zone || request.pricing().mode()==PriceMode.KILOMETER;
|
||||
if(request.stops().size()==1 && !automatic)return null;
|
||||
if(request.stops().size()==1)throw invalid("Eine automatische Streckenberechnung benötigt mindestens zwei Stationen.");
|
||||
BigDecimal threshold=parameterNumber(0,"LONGHAUL_KM",BigDecimal.ZERO);
|
||||
boolean detect=threshold.signum()>0 && (catalog.enabled(0,"LONGHAUL_ACTIVE") || automatic || foreign);
|
||||
if(!foreign && !automatic && !detect)return null;
|
||||
if(pzmUrl.isBlank() && catalog.parameter(0,"URL_PZM").isBlank())throw unavailable("Die Routenberechnung ist noch nicht konfiguriert (PZM_URL).");
|
||||
List<Point> points=request.stops().stream().map(this::geocode).toList();
|
||||
long dispatch=domesticHq;
|
||||
List<HeadquartersRoute> compared=new ArrayList<>();
|
||||
if(foreign) {
|
||||
var branches=db.queryForList("SELECT hq_id,hq_mnemonic,hq_gps_lat,hq_gps_long FROM headquarters WHERE hq_gps_lat>0 AND hq_gps_long>0 ORDER BY hq_id");
|
||||
if(branches.isEmpty())throw invalid("Für die Auslandsabholung fehlen die Niederlassungskoordinaten.");
|
||||
Map<Long,Map<String,Object>> candidates=new LinkedHashMap<>();
|
||||
for(Point end:List.of(points.getFirst(),points.getLast()))branches.stream()
|
||||
.sorted(Comparator.comparingDouble(h -> squared(point(h),end))).limit(3).forEach(h -> candidates.put(id(h,"hq_id"),h));
|
||||
for(var h:candidates.values()) {
|
||||
var round=new ArrayList<Point>();round.add(point(h));round.addAll(points);round.add(point(h));
|
||||
BigDecimal km=kilometers(distance(route(round,0,true,false)));
|
||||
if(km.signum()<=0)throw unavailable("Eine Niederlassungsroute konnte nicht bestimmt werden. Bitte die Berechnung erneut versuchen.");
|
||||
compared.add(new HeadquartersRoute(id(h,"hq_id"),text(h,"hq_mnemonic"),km));
|
||||
}
|
||||
compared.sort(Comparator.comparing(HeadquartersRoute::roundTripKm).thenComparingLong(HeadquartersRoute::id));
|
||||
dispatch=compared.getFirst().id();
|
||||
}
|
||||
long pricingHq=catalog.enabled(0,"BWV_ACTIVE")?dispatch:user.headquartersId();
|
||||
JsonNode zones=route(points,pricingHq,!zone,zone);
|
||||
BigDecimal eligibilityKm=kilometers(zone?totalDistance(zones):distance(zones));
|
||||
boolean eligible=detect && eligibilityKm.signum()>0 && eligibilityKm.compareTo(threshold.multiply(BigDecimal.valueOf(points.size()-1)))>=0;
|
||||
boolean longhaul=eligible && request.longhaul()!=LonghaulMode.ASAP;
|
||||
if(zone) {
|
||||
if(longhaul) {
|
||||
BigDecimal best=totalDistance(zones);
|
||||
for(long candidate:routeHeadquarters(zones.path("poliline").asText())) {
|
||||
if(candidate==pricingHq)continue;
|
||||
JsonNode alternative=route(points,candidate,false,true);
|
||||
if(totalDistance(alternative).compareTo(best)<0) {pricingHq=candidate;zones=alternative;best=totalDistance(alternative);}
|
||||
}
|
||||
} else zones=route(points,pricingHq,false,false);
|
||||
}
|
||||
var names=db.queryForList("SELECT hq_mnemonic FROM headquarters WHERE hq_id=?",dispatch);
|
||||
String name=names.isEmpty()?Long.toString(dispatch):text(names.getFirst(),"hq_mnemonic");
|
||||
BigDecimal calculation=distance(zones);
|
||||
if(zone) {
|
||||
calculation=calculation.add(groupDistance(zones,"extraApproachDistance"));
|
||||
if(!(longhaul && catalog.enabled(pricingHq,"PZM_ROUNDTRIPKM")))calculation=calculation.add(groupDistance(zones,"extraDepartureCalculation"));
|
||||
}
|
||||
return new Result(new RouteInfo(kilometers(distance(zones)),kilometers(calculation),duration(zones),dispatch,name,pricingHq,
|
||||
eligible,longhaul,List.copyOf(compared)),zones);
|
||||
}
|
||||
BigDecimal sustainabilityDistance(LegacyUser user,Request request) {
|
||||
if(request.stops().size()<2)throw invalid("Für automatische Nachhaltigkeitskilometer werden mindestens zwei Stationen benötigt.");
|
||||
return kilometers(distance(route(request.stops().stream().map(this::geocode).toList(),user.headquartersId(),true,false)));
|
||||
}
|
||||
private static final java.util.regex.Pattern COORDINATES=java.util.regex.Pattern.compile(".*\\((-?[0-9]{1,3}(?:\\.[0-9]+)?),\\s*(-?[0-9]{1,3}(?:\\.[0-9]+)?)\\)$");
|
||||
static Point coordinates(String street) {
|
||||
var matcher=COORDINATES.matcher(street);
|
||||
if(!matcher.matches())return null;
|
||||
return new Point(Double.parseDouble(matcher.group(1)),Double.parseDouble(matcher.group(2)));
|
||||
}
|
||||
MapAddress reverse(MapPoint point) {
|
||||
var location=new Point(point.latitude().doubleValue(),point.longitude().doubleValue());
|
||||
String suffix=" ("+BigDecimal.valueOf(location.latitude()).setScale(6,RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()+","+BigDecimal.valueOf(location.longitude()).setScale(6,RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()+")";
|
||||
if(apiKey().isBlank())return new MapAddress("GPS"+suffix,"","","","","Der Kartenpunkt ist gewählt. Automatische Adressermittlung benötigt GOOGLE_MAPS_API_KEY. Bitte Land, PLZ, Ort und Hausnummer prüfen/ergänzen.");
|
||||
JsonNode response=cached("geo:reverse:"+location.station(),() -> client.get(geocodeUrl+"?latlng="+encode(location.station())+"&language=de&key="+encode(apiKey()),"Die Adressermittlung"));
|
||||
if(response.path("status").asText().equals("ZERO_RESULTS"))return new MapAddress("GPS"+suffix,"","","","","Für den Kartenpunkt wurde keine Adresse gefunden. Bitte die Adressfelder ergänzen.");
|
||||
if(!response.path("status").asText().equals("OK") || !response.path("results").isArray() || response.path("results").isEmpty())throw unavailable("Die Adresse zum Kartenpunkt konnte nicht ermittelt werden.");
|
||||
var parts=new HashMap<String,String>();
|
||||
for(var component:response.path("results").get(0).path("address_components"))for(var type:component.path("types"))
|
||||
parts.put(type.asText(),component.path(type.asText().equals("country")?"short_name":"long_name").asText());
|
||||
String street=parts.getOrDefault("route","GPS");
|
||||
street=street.substring(0,Math.min(street.length(),50-suffix.length()))+suffix;
|
||||
String country=parts.getOrDefault("country","");
|
||||
if(!country.isEmpty() && !country.matches("[A-Z]{2}"))throw unavailable("Die Adressermittlung lieferte kein gültiges Land.");
|
||||
return new MapAddress(street,parts.getOrDefault("street_number",""),parts.getOrDefault("postal_code",""),parts.getOrDefault("locality",parts.getOrDefault("postal_town","")),country,"");
|
||||
}
|
||||
Point geocode(Station station) {
|
||||
Point selected=coordinates(station.street());if(selected!=null)return selected;
|
||||
// Cache is shared with PHP when the legacy geo schema is available, but never modified here.
|
||||
var tables=db.queryForList("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='address_geo' AND TABLE_NAME IN ('address','address_geo')");
|
||||
if(tables.size()==2) {
|
||||
var rows=db.queryForList("""
|
||||
SELECT g.adg_gps_lat,g.adg_gps_long,g.adg_modify FROM address_geo.address a
|
||||
JOIN address_geo.address_geo g ON g.ad_id=a.ad_id
|
||||
WHERE (a.ad_country=? OR (?='DE' AND a.ad_country='D')) AND a.ad_zipcode=? AND a.ad_city=?
|
||||
AND a.ad_street=? AND g.adg_hsno=? AND g.adg_accuracy>=6 ORDER BY g.adg_modify DESC LIMIT 1
|
||||
""",station.country(),station.country(),station.postalCode(),station.city(),station.street(),station.houseNumber());
|
||||
if(!rows.isEmpty()) {
|
||||
var row=rows.getFirst();LocalDateTime modified=dateTime(row,"adg_modify");
|
||||
int days=parameterNumber(0,"GEOCACHE_LIFETIME",BigDecimal.valueOf(365)).intValue();
|
||||
if(modified!=null && days>0 && modified.isAfter(LocalDateTime.now().minusDays(days)))
|
||||
return new Point(number(row,"adg_gps_lat").doubleValue(),number(row,"adg_gps_long").doubleValue());
|
||||
}
|
||||
}
|
||||
String address=station.street()+" "+station.houseNumber()+", "+station.postalCode()+" "+station.city()+", "+station.country();
|
||||
String key=apiKey();if(key.isBlank())throw unavailable("Für neue Adressen fehlt die Konfiguration GOOGLE_MAPS_API_KEY.");
|
||||
String cacheKey="geo:"+address;
|
||||
JsonNode response=cached(cacheKey,() -> client.get(geocodeUrl+"?address="+encode(address)+"&components=country:"+station.country()+"&key="+encode(key),"Die Geokodierung"));
|
||||
String status=response.path("status").asText();
|
||||
if(!Set.of("OK","ZERO_RESULTS").contains(status))throw unavailable("Die Geokodierung ist momentan nicht verfügbar. Bitte Dienstkonfiguration und Kontingent prüfen.");
|
||||
if(!status.equals("OK") || response.path("results").size()!=1)
|
||||
throw invalid("Die Adresse in "+station.postalCode()+" "+station.city()+" ist nicht eindeutig geokodierbar. Bitte Straße und Hausnummer prüfen.");
|
||||
var result=response.path("results").get(0);
|
||||
if(result.path("partial_match").asBoolean() || !Set.of("ROOFTOP","RANGE_INTERPOLATED","GEOMETRIC_CENTER").contains(result.path("geometry").path("location_type").asText()))
|
||||
throw invalid("Die Adresse in "+station.postalCode()+" "+station.city()+" ist für die Routenberechnung zu ungenau.");
|
||||
boolean precise=false;for(var type:result.path("types"))if(Set.of("street_address","premise","subpremise","route","establishment","point_of_interest").contains(type.asText()))precise=true;
|
||||
if(!precise)throw invalid("Die Geokodierung hat nur einen Ort oder PLZ-Bereich gefunden. Bitte die vollständige Stationsadresse prüfen.");
|
||||
boolean country=false;for(var component:result.path("address_components"))for(var type:component.path("types"))
|
||||
if(type.asText().equals("country") && component.path("short_name").asText().equals(station.country()))country=true;
|
||||
if(!country)throw invalid("Das geokodierte Land stimmt nicht mit der Station überein.");
|
||||
var location=result.path("geometry").path("location");
|
||||
if(!location.path("lat").isNumber() || !location.path("lng").isNumber())throw unavailable("Die Geokodierung hat keine Koordinaten geliefert.");
|
||||
return new Point(location.path("lat").asDouble(),location.path("lng").asDouble());
|
||||
}
|
||||
JsonNode route(List<Point> points,long hq,boolean distanceOnly,boolean longhaul) {
|
||||
var payload=new LinkedHashMap<String,Object>();
|
||||
payload.put("stations",points.stream().map(Point::station).toList());payload.put("hq_id",hq);payload.put("jb_id",0);
|
||||
payload.put("traffic",false);payload.put("optimized",false);payload.put("distanceOnly",distanceOnly);
|
||||
String shortest=catalog.parameter(hq,"PZM_SHORTEST");
|
||||
payload.put("shortest",longhaul || shortest.equals("1"));payload.put("ridiculousShortest",!longhaul && shortest.equals("2"));
|
||||
payload.put("roundTripKm",!longhaul && catalog.enabled(hq,"PZM_ROUNDTRIPKM"));payload.put("api_key",apiKey());
|
||||
String url=pzmUrl.isBlank()?catalog.parameter(0,"URL_PZM"):pzmUrl;
|
||||
if(url.isBlank())throw unavailable("Die Routenberechnung ist noch nicht konfiguriert (PZM_URL).");
|
||||
String cacheKey=LegacyOrderPricing.hash(url+payload);
|
||||
JsonNode result=cached(cacheKey,() -> {
|
||||
try {return client.post(url,payload,"Die Routenberechnung");}
|
||||
catch(org.springframework.web.server.ResponseStatusException e) {
|
||||
if(fallbackUrl.isBlank())throw e;
|
||||
return client.post(fallbackUrl,payload,"Die alternative Routenberechnung");
|
||||
}
|
||||
});
|
||||
try {
|
||||
distance(result);
|
||||
if(!distanceOnly)for(String field:List.of("zoneDistance","extraApproachDistance","extraDepartureCalculation","eapLeg","edcLeg"))
|
||||
if(!result.path(field).isArray())throw unavailable("Die Routenberechnung hat keine vollständigen Tarifzonen geliefert.");
|
||||
return result;
|
||||
}catch(org.springframework.web.server.ResponseStatusException failure){cache.remove(cacheKey);throw failure;}
|
||||
}
|
||||
private JsonNode cached(String key,java.util.function.Supplier<JsonNode> fetch) {
|
||||
Cached saved=cache.get(key);if(saved!=null && saved.at().isAfter(Instant.now().minusSeconds(300)))return saved.value();
|
||||
JsonNode value=fetch.get();
|
||||
if(!key.startsWith("geo:") || value.path("status").asText().equals("OK"))
|
||||
synchronized(cache) {if(cache.size()>=512)cache.remove(cache.keySet().iterator().next());cache.put(key,new Cached(Instant.now(),value));}
|
||||
return value;
|
||||
}
|
||||
String apiKey(){String key=configuredKey.isBlank()?catalog.parameter(0,"GOOGLE_MAPS_API_KEY"):configuredKey;return key.split(",",-1)[0].trim();}
|
||||
private static String encode(String value){return URLEncoder.encode(value,StandardCharsets.UTF_8);}
|
||||
BigDecimal parameterNumber(long hq,String key,BigDecimal fallback) {
|
||||
String value=catalog.parameter(hq,key);if(value.isBlank())return fallback;
|
||||
try{return new BigDecimal(value.trim().replace(',','.'));}catch(NumberFormatException e){throw invalid("Der Bestandsparameter "+key+" ist ungültig.");}
|
||||
}
|
||||
static BigDecimal distance(JsonNode node) {return metric(node,"distance");}
|
||||
static BigDecimal metric(JsonNode node,String name) {
|
||||
JsonNode value=node.path(name);
|
||||
if(!value.isNumber() || value.decimalValue().signum()<0 || value.decimalValue().compareTo(new BigDecimal("1000000000"))>0)
|
||||
throw unavailable("Die Routenberechnung hat eine ungültige Entfernung geliefert.");
|
||||
return value.decimalValue();
|
||||
}
|
||||
static BigDecimal groupDistance(JsonNode node,String field) {BigDecimal sum=BigDecimal.ZERO;for(var part:node.path(field))sum=sum.add(metric(part,"distance"));return sum;}
|
||||
static BigDecimal totalDistance(JsonNode node) {
|
||||
BigDecimal sum=distance(node);
|
||||
for(String field:List.of("eapLeg","edcLeg"))for(var leg:node.path(field))sum=sum.add(metric(leg,"distanceMeters"));
|
||||
return sum;
|
||||
}
|
||||
static BigDecimal kilometers(BigDecimal meters){return meters.divide(BigDecimal.valueOf(1000),2,RoundingMode.HALF_UP);}
|
||||
static long duration(JsonNode node) {
|
||||
String value=node.path("duration").asText("0s");
|
||||
try {return new BigDecimal(value.replace("s","")).max(BigDecimal.ZERO).setScale(0,RoundingMode.HALF_UP).longValueExact();}
|
||||
catch(ArithmeticException|NumberFormatException e){throw unavailable("Die Routenberechnung hat eine ungültige Fahrzeit geliefert.");}
|
||||
}
|
||||
private static Point point(Map<String,Object> h){return new Point(number(h,"hq_gps_lat").doubleValue(),number(h,"hq_gps_long").doubleValue());}
|
||||
private static double squared(Point a,Point b){return Math.pow(a.latitude()-b.latitude(),2)+Math.pow(a.longitude()-b.longitude(),2);}
|
||||
|
||||
/** PHP samples the returned polyline every 2 km and checks the existing postal polygons. */
|
||||
Set<Long> routeHeadquarters(String encoded) {
|
||||
List<Point> points=decodePolyline(encoded);Set<Long> result=new TreeSet<>();double accumulated=0;
|
||||
for(int i=1;i<points.size();i++) {
|
||||
Point a=points.get(i-1),b=points.get(i);
|
||||
double dlat=Math.toRadians(b.latitude()-a.latitude()),dlon=Math.toRadians(b.longitude()-a.longitude());
|
||||
double hav=Math.pow(Math.sin(dlat/2),2)+Math.cos(Math.toRadians(a.latitude()))*Math.cos(Math.toRadians(b.latitude()))*Math.pow(Math.sin(dlon/2),2);
|
||||
accumulated+=6371*2*Math.asin(Math.min(1,Math.sqrt(hav)));
|
||||
if(accumulated<2)continue;accumulated%=2;
|
||||
var polygons=db.queryForList("""
|
||||
SELECT p.plzp_polygons,s.hq_id FROM phoenix_special.plz_geodb g
|
||||
JOIN phoenix_special.plz_polygon p ON p.plzp_code=g.plzg_code
|
||||
JOIN serviceplz s ON s.srvp_plz=g.plzg_code
|
||||
WHERE ACOS(LEAST(1,GREATEST(-1,SIN(RADIANS(?))*SIN(RADIANS(g.plzg_breite))
|
||||
+COS(RADIANS(?))*COS(RADIANS(g.plzg_breite))*COS(RADIANS(g.plzg_laenge)-RADIANS(?))))) * 6371 < 50
|
||||
ORDER BY g.plzg_code
|
||||
""",b.latitude(),b.latitude(),b.longitude());
|
||||
for(var polygon:polygons)if(contains(text(polygon,"plzp_polygons"),b)){long id=id(polygon,"hq_id");if(id>0)result.add(id);break;}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static boolean contains(String polygons,Point point) {
|
||||
for(String polygon:polygons.split("\\|")) {
|
||||
var points=new ArrayList<Point>();
|
||||
for(String coordinate:polygon.split(",")) {String[] pair=coordinate.trim().split("\\s+");if(pair.length!=2)throw unavailable("Ungültiges PLZ-Polygon im Bestand.");
|
||||
try {points.add(new Point(Double.parseDouble(pair[1]),Double.parseDouble(pair[0])));}catch(NumberFormatException e){throw unavailable("Ungültiges PLZ-Polygon im Bestand.");}}
|
||||
boolean inside=false;
|
||||
for(int i=0,j=points.size()-1;i<points.size();j=i++) {Point a=points.get(i),b=points.get(j);
|
||||
if((a.latitude()>point.latitude())!=(b.latitude()>point.latitude()) && point.longitude()<(b.longitude()-a.longitude())*(point.latitude()-a.latitude())/(b.latitude()-a.latitude())+a.longitude())inside=!inside;}
|
||||
if(inside)return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static List<Point> decodePolyline(String encoded) {
|
||||
if(encoded.isBlank())throw unavailable("Für die Fernverkehrsoptimierung fehlt der Routenverlauf.");
|
||||
List<Point> result=new ArrayList<>();int index=0;long lat=0,lon=0;
|
||||
try {
|
||||
while(index<encoded.length()) {
|
||||
long[] delta=new long[2];
|
||||
for(int k=0;k<2;k++) {int shift=0,bits;long value=0;
|
||||
do {bits=encoded.charAt(index++)-63;if(bits<0||bits>63||shift>30)throw new IllegalArgumentException();value|=(long)(bits&31)<<shift;shift+=5;}while(bits>=32);
|
||||
delta[k]=(value&1)!=0?~(value>>1):value>>1;}
|
||||
lat+=delta[0];lon+=delta[1];result.add(new Point(lat/1e5,lon/1e5));
|
||||
if(result.size()>50000)throw new IllegalArgumentException();
|
||||
}
|
||||
}catch(IndexOutOfBoundsException|IllegalArgumentException e){throw unavailable("Die Routenberechnung hat einen ungültigen Routenverlauf geliefert.");}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import java.math.*;
|
||||
import java.util.*;
|
||||
import java.time.Instant;
|
||||
import static de.stadtbote.votian.OrderEntryModels.*;
|
||||
import static de.stadtbote.votian.OrderEntryCatalog.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.LegacyOrderPricing.hash;
|
||||
|
||||
/** co2e_sendReq.php contract: data[referenz,mlp,gewicht,gefahreneKm] -> data[referenz,co2]. */
|
||||
@Service
|
||||
public class OrderSustainability {
|
||||
private final OrderEntryCatalog catalog;private final OrderRoutingService routing;private final OrderIntegrationClient client;
|
||||
private final String url,username,password;
|
||||
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
private record Cached(Instant expires,SustainabilityResult value) {}
|
||||
private final Map<String,Cached> cache=new java.util.concurrent.ConcurrentHashMap<>();
|
||||
public OrderSustainability(OrderEntryCatalog catalog,OrderRoutingService routing,OrderIntegrationClient client,
|
||||
@Value("${app.sustainability.url:}") String url,@Value("${app.sustainability.username:}") String username,@Value("${app.sustainability.password:}") String password) {
|
||||
this.catalog=catalog;this.routing=routing;this.client=client;this.url=url;this.username=username;this.password=password;
|
||||
}
|
||||
private record Input(BigDecimal km,BigDecimal weight,BigDecimal mlp,String evidence,String fingerprint) {}
|
||||
private Input input(LegacyUser user,Request request,Prepared prepared,BigDecimal km) {
|
||||
if(request.mode()!=Mode.RETROSPECTIVE)throw invalid("Nachhaltigkeitswerte können bei abgeschlossenen Nacherfassungen erfasst werden.");
|
||||
if(km==null || km.signum()<=0 || km.compareTo(new BigDecimal("999999.99"))>0)throw invalid("Für Nachhaltigkeitsdaten fehlen gültige gefahrene Kilometer.");
|
||||
BigDecimal weight=request.weight()!=null && request.weight().signum()>0?request.weight():number(prepared.vehicle(),"crvh_payload").multiply(new BigDecimal("0.5"));
|
||||
int badge=(int)id(prepared.vehicle(),"crvh_env_badge_level");
|
||||
String[] small={"0.13","0.13","0.1255","0.133","0.134","0.1285","0.13"},large={"0.13","0.1755","0.17","0.181","0.1795","0.1715","0.1755"};
|
||||
BigDecimal mlp=new BigDecimal((id(prepared.vehicle(),"vht_id")<=7?small:large)[badge>=1 && badge<=6?badge:0]);
|
||||
String evidence="T="+weight+"|L="+factor(user,"CO2_FORMULAR_L","1.2")+"|KM="+km+"|MLP="+mlp+"|U="+factor(user,"CO2_FORMULAR_U","3.15")+"|TF="+factor(user,"CO2_FORMULAR_TF","1000");
|
||||
return new Input(km,weight,mlp,evidence,hash(user.headquartersId()+"|"+request.courierVehicleId()+"|"+request.stops()+"|"+evidence));
|
||||
}
|
||||
private String factor(LegacyUser user,String key,String fallback) {
|
||||
String value=catalog.parameter(user.headquartersId(),key);if(value==null || value.isBlank())value=catalog.parameter(0,key);
|
||||
if(value==null || value.isBlank())return fallback;
|
||||
try {var number=new BigDecimal(value);return number.signum()>0?number.toPlainString():fallback;}catch(NumberFormatException e){return fallback;}
|
||||
}
|
||||
SustainabilityResult resolve(LegacyUser user,Request request,Prepared prepared,OrderRoutingService.Result route) {
|
||||
var choice=request.sustainability();if(choice==null)return null;
|
||||
if(request.mode()!=Mode.RETROSPECTIVE)throw invalid("Nachhaltigkeitswerte können nur bei Nacherfassungen erfasst werden.");
|
||||
if(choice.mode()==SustainabilityMode.MANUAL) {
|
||||
if(choice.distanceKm()==null || choice.co2eKg()==null)throw invalid("Bitte gefahrene Kilometer und CO₂e für die manuelle Erfassung angeben.");
|
||||
return new SustainabilityResult(choice.distanceKm(),choice.co2eKg(),"Manuell","m",hash(choice.toString()));
|
||||
}
|
||||
if(choice.distanceKm()!=null || choice.co2eKg()!=null)throw invalid("Automatische und manuelle Nachhaltigkeitswerte können nicht kombiniert werden.");
|
||||
if(url.isBlank() || username.isBlank() || password.isBlank())throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst ist noch nicht konfiguriert (CO2_URL, CO2_USERNAME, CO2_PASSWORD). Alternativ geprüfte Werte manuell erfassen.");
|
||||
BigDecimal km=request.distanceKm()!=null && request.distanceKm().signum()>0?request.distanceKm():route!=null?route.info().distanceKm():routing.sustainabilityDistance(user,request);
|
||||
var input=input(user,request,prepared,km);
|
||||
if(input.weight().signum()<=0)throw invalid("Bitte Sendungsgewicht oder Fahrzeugnutzlast für die CO₂e-Berechnung hinterlegen.");
|
||||
String key=hash(request.requestId()+"|"+input.fingerprint());var cached=cache.get(key);
|
||||
if(cached!=null && cached.expires().isAfter(Instant.now()))return cached.value();
|
||||
// Calculation reference is request-scoped; no order exists yet and no booking is sent.
|
||||
long reference=Long.parseLong(hash(request.requestId()).substring(0,7),16);
|
||||
var payload=Map.of("data",List.of(Map.of("referenz",reference,"mlp",input.mlp(),"gewicht",input.weight(),"gefahreneKm",input.km())));
|
||||
var response=client.postAuthenticated(url,payload,"Der CO₂e-Dienst",username,password);
|
||||
if(response==null || response.hasNonNull("errors") && !response.path("errors").isEmpty() || !response.path("data").isArray() || response.path("data").size()!=1)
|
||||
throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst hat kein eindeutiges Ergebnis geliefert.");
|
||||
var item=response.path("data").get(0);BigDecimal co2;
|
||||
try {co2=new BigDecimal(item.path("co2").asText()).setScale(2,RoundingMode.HALF_UP);}catch(Exception e){throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst hat keinen gültigen Emissionswert geliefert.");}
|
||||
if(!Long.toString(reference).equals(item.path("referenz").asText()) || co2.signum()<=0 || co2.compareTo(new BigDecimal("999999.99"))>0)
|
||||
throw OrderIntegrationClient.unavailable("Der CO₂e-Dienst lieferte einen ungültigen oder fremden Berechnungsbeleg.");
|
||||
var result=new SustainabilityResult(km,co2,"CO₂e-Dienst",input.evidence(),input.fingerprint());
|
||||
cache.entrySet().removeIf(e -> e.getValue().expires().isBefore(Instant.now()));if(cache.size()>=500)cache.clear();
|
||||
cache.put(key,new Cached(Instant.now().plusSeconds(300),result));return result;
|
||||
}
|
||||
Quote apply(LegacyUser user,Request request,Prepared prepared,Quote quote,SustainabilityResult value) {
|
||||
if(value==null)return quote;
|
||||
if(request.sustainability().mode()==SustainabilityMode.AUTO && !input(user,request,prepared,value.distanceKm()).fingerprint().equals(value.inputFingerprint()))
|
||||
throw conflict("Fahrzeug- oder Nachhaltigkeitsdaten haben sich geändert. Bitte neu berechnen.");
|
||||
ObjectNode tree=json.valueToTree(quote);tree.set("sustainability",json.valueToTree(value));tree.put("fingerprint",hash(tree.toString()));
|
||||
try{return json.treeToValue(tree,Quote.class);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Native port of groupware/report.php. Reports are MyISAM: each mutation is one CAS statement. */
|
||||
@Service
|
||||
public class ReportAdministration {
|
||||
static final String TABLE = "`phoenix_group`.report_process";
|
||||
private static final String COLUMNS = "r.rp_id,r.hq_id,r.rp_objtype,r.rp_objid,r.usr_id,r.rp_reporttype,r.rp_text,r.rp_confidential,r.rp_createtime";
|
||||
private static final List<String> REVISION_KEYS = List.of("rp_id","hq_id","rp_objtype","rp_objid","usr_id","rp_reporttype","rp_text","rp_confidential","rp_createtime");
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
public ReportAdministration(DatabaseService db, OrderEntryCatalog catalog) { this.db=db; this.catalog=catalog; }
|
||||
|
||||
enum Kind {
|
||||
CUSTOMER("cs", "customer", "report_type", 0), COURIER("cr", "courier", "report_type_2", 1);
|
||||
final String prefix, table, type; final int right;
|
||||
Kind(String prefix,String table,String type,int right) { this.prefix=prefix;this.table=table;this.type=type;this.right=right; }
|
||||
String objectLabel() { return "CONCAT_WS(' ',o."+prefix+"_eid,c.cmp_comp,c.cmp_comp2)"; }
|
||||
}
|
||||
enum Sort { DATE, ID, HEADQUARTERS, OBJECT, AUTHOR, TEXT, TYPE }
|
||||
record Query(LocalDate from,LocalDate to,List<Long> headquarters,Long objectId,Integer type,String search,int page,Sort sort,boolean descending,boolean splitType,boolean splitCustomer) {}
|
||||
record Save(String revision,long objectId,int type,String text,boolean confidential) {}
|
||||
record Sql(String fromWhere,Object[] args) {}
|
||||
private void access(LegacyUser user,Kind kind) { user.require(16);user.require(kind.right); }
|
||||
|
||||
public Map<String,Object> options(LegacyUser user,Kind kind) {
|
||||
access(user,kind);
|
||||
LocalDate today=LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
return fields("types",types(kind.type),"headquarters",headquarters(user),"currentHeadquarters",user.headquartersId(),
|
||||
"from",LocalDate.of(today.getYear(),today.getMonthValue()<7?1:7,1),"to",today,
|
||||
"miscType",catalog.parameter(0,"MASK_CS_REPORTTYPE_MISC_ITEM"),
|
||||
"canCreateAppointments",kind==Kind.CUSTOMER&&user.hasRight(11),"completeAppointments",kind==Kind.CUSTOMER&&catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY"));
|
||||
}
|
||||
private List<Map<String,Object>> types(String type) {
|
||||
// Aggregate duplicate legacy labels without multiplying report/statistic rows.
|
||||
return db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type=? GROUP BY mt_sort ORDER BY label,mt_sort",type);
|
||||
}
|
||||
List<Map<String,Object>> headquarters(LegacyUser user) {
|
||||
return db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(user.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",
|
||||
user.hasRight(10)?new Object[]{user.headquartersId()}:new Object[]{user.headquartersId(),user.headquartersId()});
|
||||
}
|
||||
private List<Long> scope(LegacyUser user,List<Long> requested) {
|
||||
if(requested==null||requested.isEmpty()||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine gültige Niederlassung wählen.");
|
||||
var ids=requested.stream().distinct().sorted().toList();
|
||||
if(ids.size()==1&&ids.getFirst()==user.headquartersId())return ids;
|
||||
user.require(10);
|
||||
var allowed=headquarters(user).stream().map(r->id(r,"id")).toList();
|
||||
if(!allowed.containsAll(ids))throw new AccessDeniedException("Eine Niederlassung gehört nicht zum freigegebenen Mandanten.");
|
||||
return ids;
|
||||
}
|
||||
private static String placeholders(int count) { return String.join(",",Collections.nCopies(count,"?")); }
|
||||
public List<Map<String,Object>> objects(LegacyUser user,Kind kind,List<Long> requested,String search) {
|
||||
access(user,kind);var hqs=scope(user,requested);
|
||||
if(search==null||search.length()>100)throw bad("Bitte höchstens 100 Suchzeichen eingeben.");
|
||||
var args=new ArrayList<Object>(hqs);args.add("%"+search.strip()+"%");
|
||||
return db.queryForList("SELECT o."+kind.prefix+"_id AS id,"+kind.objectLabel()+" AS label,h.hq_mnemonic AS headquarters FROM "+kind.table+" o LEFT JOIN company c ON c.cmp_id=o.cmp_id JOIN headquarters h ON h.hq_id=o.hq_id WHERE o.hq_id IN ("+placeholders(hqs.size())+") AND "+kind.objectLabel()+" LIKE ? ORDER BY label,id LIMIT 50",args.toArray());
|
||||
}
|
||||
private void object(LegacyUser user,Kind kind,long objectId) {
|
||||
if(objectId<=0)throw bad("Bitte einen Kunden oder Transporteur wählen.");
|
||||
var hqs=headquarters(user).stream().map(r->id(r,"id")).toList();
|
||||
if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");
|
||||
var args=new ArrayList<Object>(List.of(objectId));args.addAll(hqs);
|
||||
one(db.queryForList("SELECT "+kind.prefix+"_id FROM "+kind.table+" WHERE "+kind.prefix+"_id=? AND hq_id IN ("+placeholders(hqs.size())+")",args.toArray()));
|
||||
}
|
||||
static void validate(Query q,Kind kind) {
|
||||
if(q==null||q.sort()==null)throw bad("Berichtsfilter fehlen.");
|
||||
range(q.from(),q.to());
|
||||
if(q.from().getYear()<1000||q.to().getYear()>9998)throw bad("Bitte einen Zeitraum zwischen den Jahren 1000 und 9998 wählen.");
|
||||
if(q.page()<0||q.page()>1_000_000||q.search()==null||q.search().length()>200||q.objectId()!=null&&q.objectId()<=0||q.type()!=null&&(q.type()<0||q.type()>127))throw bad("Ungültiger Berichtsfilter.");
|
||||
if(kind==Kind.COURIER&&q.splitCustomer())throw bad("Die Aufteilung nach Kundentyp ist nur für Kundenberichte möglich.");
|
||||
}
|
||||
private Sql selection(LegacyUser user,Kind kind,Query q) {
|
||||
validate(q,kind);var hqs=scope(user,q.headquarters());
|
||||
if(q.objectId()!=null)object(user,kind,q.objectId());
|
||||
var args=new ArrayList<Object>();args.add(kind.prefix);args.addAll(hqs);args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());
|
||||
String where="r.rp_objtype=? AND r.hq_id IN ("+placeholders(hqs.size())+") AND r.rp_createtime>=? AND r.rp_createtime<?";
|
||||
if(!user.hasRight(10)){where+=" AND (r.rp_confidential=0 OR r.usr_id=?)";args.add(user.userId());}
|
||||
if(q.objectId()!=null) {
|
||||
where+=kind==Kind.CUSTOMER?" AND (r.rp_objid=? OR o.cs_id_parent=?)":" AND r.rp_objid=?";
|
||||
args.add(q.objectId());if(kind==Kind.CUSTOMER)args.add(q.objectId());
|
||||
}
|
||||
if(q.type()!=null){where+=" AND r.rp_reporttype=?";args.add(q.type());}
|
||||
if(!q.search().isBlank()){where+=" AND r.rp_text LIKE ?";args.add("%"+q.search().strip()+"%");}
|
||||
return new Sql(joins(kind)+" WHERE "+where,args.toArray());
|
||||
}
|
||||
private static String joins(Kind kind) {
|
||||
return " FROM "+TABLE+" r LEFT JOIN "+kind.table+" o ON o."+kind.prefix+"_id=r.rp_objid LEFT JOIN company c ON c.cmp_id=o.cmp_id LEFT JOIN user u ON u.usr_id=r.usr_id LEFT JOIN headquarters h ON h.hq_id=r.hq_id";
|
||||
}
|
||||
public Map<String,Object> list(LegacyUser user,Kind kind,Query q) {
|
||||
access(user,kind);
|
||||
return db.readTransaction(()->{
|
||||
var sql=selection(user,kind,q);var typeLabels=labels(types(kind.type));
|
||||
String sort=switch(q.sort()){case DATE->"r.rp_createtime";case ID->"r.rp_id";case HEADQUARTERS->"h.hq_mnemonic";case OBJECT->"object_label";case AUTHOR->"u.usr_name,u.usr_firstname";case TEXT->"r.rp_text";case TYPE->"r.rp_reporttype";};
|
||||
String direction=q.descending()?" DESC":" ASC";sort=String.join(direction+",",sort.split(","))+direction;
|
||||
var args=new ArrayList<Object>(Arrays.asList(sql.args()));args.add(q.page()*100);
|
||||
var rows=db.queryForList("SELECT "+COLUMNS+","+kind.objectLabel()+" AS object_label,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,u.usr_phone AS phone"+sql.fromWhere()+" ORDER BY "+sort+",r.rp_id"+direction+" LIMIT 100 OFFSET ?",args.toArray());
|
||||
long total=id(one(db.queryForList("SELECT COUNT(*) AS total"+sql.fromWhere(),sql.args())),"total");
|
||||
return fields("items",rows.stream().map(r->view(user,r,typeLabels)).toList(),"total",total,"page",q.page(),"pageSize",100);
|
||||
});
|
||||
}
|
||||
public Map<String,Object> statistics(LegacyUser user,Kind kind,Query q) {
|
||||
access(user,kind);
|
||||
return db.readTransaction(()->{
|
||||
var sql=selection(user,kind,q);
|
||||
String extras=(q.splitType()?",r.rp_reporttype":"")+(q.splitCustomer()?",c.cmp_type":"");
|
||||
var rows=db.queryForList("SELECT r.hq_id,r.usr_id,MIN(h.hq_mnemonic) AS headquarters,MIN(CONCAT_WS(' ',u.usr_firstname,u.usr_name)) AS author"+extras+",COUNT(*) AS total"+sql.fromWhere()+" GROUP BY r.hq_id,r.usr_id"+extras+" ORDER BY headquarters,author,r.usr_id"+extras+" LIMIT 5001",sql.args());
|
||||
if(rows.size()>5000)throw bad("Mehr als 5000 Statistikzeilen. Bitte den Zeitraum oder die Aufteilung eingrenzen.");
|
||||
var reportTypes=labels(types(kind.type));var customerTypes=q.splitCustomer()?labels(types("customer_type")):Map.<Long,String>of();
|
||||
return fields("total",rows.stream().mapToLong(r->id(r,"total")).sum(),"rows",rows.stream().map(r->fields("headquarters",text(r,"headquarters"),"author",author(r),"type",q.splitType()?label(reportTypes,id(r,"rp_reporttype")):"", "customerType",q.splitCustomer()?label(customerTypes,id(r,"cmp_type")):"","total",id(r,"total"))).toList());
|
||||
});
|
||||
}
|
||||
private static Map<Long,String> labels(List<Map<String,Object>> types) { var result=new HashMap<Long,String>();for(var t:types)result.put(id(t,"id"),text(t,"label"));return result; }
|
||||
private static String label(Map<Long,String> labels,long id) { return labels.getOrDefault(id,id==0?"Ohne Berichtstyp":"Typ "+id); }
|
||||
private static String author(Map<String,Object> row) { String name=text(row,"author");return name.isBlank()?"Nutzer gelöscht ("+id(row,"usr_id")+")":name; }
|
||||
static Map<String,Object> version(Map<String,Object> row) { var result=new LinkedHashMap<String,Object>();for(String key:REVISION_KEYS)result.put(key,value(row,key));return result; }
|
||||
private Map<String,Object> view(LegacyUser user,Map<String,Object> row,Map<Long,String> types) {
|
||||
return fields("id",id(row,"rp_id"),"headquarters",text(row,"headquarters"),"objectId",id(row,"rp_objid"),"objectLabel",text(row,"object_label"),"author",author(row),"phone",text(row,"phone"),
|
||||
"createdAt",dateTime(row,"rp_createtime"),"text",text(row,"rp_text"),"type",id(row,"rp_reporttype"),"typeLabel",label(types,id(row,"rp_reporttype")),"confidential",id(row,"rp_confidential")!=0,
|
||||
"canEdit",id(row,"usr_id")==user.userId()||user.hasRight(3),"canDelete",id(row,"usr_id")==user.userId(),"revision",revision(version(row)));
|
||||
}
|
||||
Map<String,Object> report(LegacyUser user,Kind kind,long reportId) {
|
||||
access(user,kind);
|
||||
var hqs=headquarters(user).stream().map(r->id(r,"id")).toList();
|
||||
if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");
|
||||
var args=new ArrayList<Object>(List.of(reportId,kind.prefix));args.addAll(hqs);
|
||||
String where="r.rp_id=? AND r.rp_objtype=? AND r.hq_id IN ("+placeholders(hqs.size())+")";
|
||||
if(!user.hasRight(10)){where+=" AND (r.rp_confidential=0 OR r.usr_id=?)";args.add(user.userId());}
|
||||
return one(db.queryForList("SELECT "+COLUMNS+" FROM "+TABLE+" r WHERE "+where,args.toArray()));
|
||||
}
|
||||
private String validText(Save request,Kind kind) {
|
||||
if(request==null)throw bad("Bericht fehlt.");
|
||||
String text=required(request.text(),65_535,"Berichtstext");
|
||||
if(text.getBytes(StandardCharsets.UTF_8).length>65_535)throw bad("Der Berichtstext ist zu lang (höchstens 65535 UTF-8-Bytes).");
|
||||
if(request.type()<0||request.type()>127||request.type()!=0&&types(kind.type).stream().noneMatch(r->id(r,"id")==request.type()))throw bad("Bitte einen vorhandenen Berichtstyp wählen.");
|
||||
return text;
|
||||
}
|
||||
public Map<String,Object> create(LegacyUser user,Kind kind,Save request) {
|
||||
access(user,kind);String text=validText(request,kind);object(user,kind,request.objectId());
|
||||
boolean complete=kind==Kind.CUSTOMER&&catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY");
|
||||
var at=LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);
|
||||
// No fallible read or second write follows the MyISAM INSERT in this transaction.
|
||||
long reportId=db.writeTransaction(()->db.insert(TABLE,fields("hq_id",user.headquartersId(),"usr_id",user.userId(),"rp_objtype",kind.prefix,"rp_objid",request.objectId(),"rp_reporttype",request.type(),"rp_text",text,"rp_confidential",request.confidential()?1:0,"rp_createtime",at)));
|
||||
String warning="";
|
||||
if(complete)try { completeAppointments(request.objectId(),user.headquartersId(),at.toLocalDate()); }
|
||||
catch(DataAccessException e) { warning="Der Bericht wurde gespeichert. Terminerinnerungen konnten noch nicht abgeschlossen werden. Bitte den Abschluss erneut versuchen."; }
|
||||
return fields("id",reportId,"warning",warning);
|
||||
}
|
||||
public Map<String,Object> save(LegacyUser user,Kind kind,long reportId,Save request) {
|
||||
access(user,kind);String text=validText(request,kind);
|
||||
db.writeTransaction(()->{
|
||||
var old=report(user,kind,reportId);
|
||||
if(id(old,"usr_id")!=user.userId())user.require(3);
|
||||
if(request.objectId()!=id(old,"rp_objid"))throw bad("Die Zuordnung eines Berichts kann nicht geändert werden.");
|
||||
unchanged(version(old),request.revision());
|
||||
var args=new ArrayList<Object>(List.of(request.type(),text,request.confidential()?1:0));args.addAll(casArgs(old));
|
||||
checkChanged(db.update("UPDATE "+TABLE+" SET rp_reporttype=?,rp_text=?,rp_confidential=? WHERE "+cas(),args.toArray()));return null;
|
||||
});
|
||||
return fields("id",reportId,"warning","");
|
||||
}
|
||||
public void remove(LegacyUser user,Kind kind,long reportId,String expected) {
|
||||
access(user,kind);
|
||||
db.writeTransaction(()->{
|
||||
var old=report(user,kind,reportId);
|
||||
if(id(old,"usr_id")!=user.userId())throw new AccessDeniedException("Nur der Verfasser darf den Bericht löschen.");
|
||||
unchanged(version(old),expected);
|
||||
checkChanged(db.update("DELETE FROM "+TABLE+" WHERE "+cas(),casArgs(old).toArray()));return null;
|
||||
});
|
||||
}
|
||||
// FOR UPDATE cannot lock MyISAM rows. Compare every mutable/scoping value, with binary text comparison.
|
||||
private static String cas() { return "rp_id=? AND hq_id=? AND rp_objtype=? AND rp_objid=? AND usr_id=? AND rp_reporttype=? AND BINARY rp_text <=> BINARY ? AND rp_confidential=? AND rp_createtime <=> ?"; }
|
||||
private static List<Object> casArgs(Map<String,Object> row) { var args=new ArrayList<Object>();for(String key:REVISION_KEYS)args.add(value(row,key));return args; }
|
||||
private static void checkChanged(int rows) { if(rows!=1)throw new ResponseStatusException(HttpStatus.CONFLICT,"Der Bericht wurde zwischenzeitlich geändert oder gelöscht. Bitte neu laden."); }
|
||||
public int retryAppointments(LegacyUser user,Kind kind,long reportId) {
|
||||
var report=report(user,kind,reportId);
|
||||
if(kind!=Kind.CUSTOMER||!catalog.enabled(0,"MASK_RP_ENABLE_DEACTIVATION_APPOINTMENT_WARNING_OF_SAME_DAY"))throw bad("Der automatische Terminabschluss ist nicht aktiviert.");
|
||||
if(id(report,"usr_id")!=user.userId())throw new AccessDeniedException("Nur der Verfasser darf den Terminabschluss wiederholen.");
|
||||
LocalDateTime at=dateTime(report,"rp_createtime");if(at==null)throw bad("Der Bericht enthält kein gültiges Erfassungsdatum.");
|
||||
return completeAppointments(id(report,"rp_objid"),id(report,"hq_id"),at.toLocalDate());
|
||||
}
|
||||
private int completeAppointments(long objectId,long hq,LocalDate day) {
|
||||
// Independently repeatable side effect. Never match courier ids against customer appointments.
|
||||
return db.writeTransaction(()->db.update("UPDATE `phoenix_group`.appointment SET ap_category_4=2 WHERE cs_id=? AND hq_id=? AND ap_execdate>=? AND ap_execdate<? AND ap_category_4=1",objectId,hq,day.atStartOfDay(),day.plusDays(1).atStartOfDay()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
import static de.stadtbote.votian.ReportAdministration.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/administration/reports/{kind}")
|
||||
public class ReportController {
|
||||
private final ReportAdministration reports;
|
||||
public ReportController(ReportAdministration reports) { this.reports=reports; }
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication auth,@PathVariable Kind kind) { return reports.options(user(auth),kind); }
|
||||
@GetMapping("/objects") List<Map<String,Object>> objects(Authentication auth,@PathVariable Kind kind,@RequestParam List<Long> headquarters,@RequestParam(defaultValue="") String q) { return reports.objects(user(auth),kind,headquarters,q); }
|
||||
@PostMapping("/query") Map<String,Object> list(Authentication auth,@PathVariable Kind kind,@RequestBody Query query) { return reports.list(user(auth),kind,query); }
|
||||
@PostMapping("/statistics") Map<String,Object> statistics(Authentication auth,@PathVariable Kind kind,@RequestBody Query query) { return reports.statistics(user(auth),kind,query); }
|
||||
@PostMapping Map<String,Object> create(Authentication auth,@PathVariable Kind kind,@RequestBody Save request) { return reports.create(user(auth),kind,request); }
|
||||
@PutMapping("/{id}") Map<String,Object> save(Authentication auth,@PathVariable Kind kind,@PathVariable long id,@RequestBody Save request) { return reports.save(user(auth),kind,id,request); }
|
||||
record Revision(String revision) {}
|
||||
@DeleteMapping("/{id}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) void remove(Authentication auth,@PathVariable Kind kind,@PathVariable long id,@RequestBody Revision request) { reports.remove(user(auth),kind,id,request.revision()); }
|
||||
@PostMapping("/{id}/complete-appointments") Map<String,Integer> appointments(Authentication auth,@PathVariable Kind kind,@PathVariable long id) { return Map.of("completed",reports.retryAppointments(user(auth),kind,id)); }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CrmScope.marks;
|
||||
|
||||
/** Report-bound PHP forms: IDs are report IDs, never newly allocated contact IDs. */
|
||||
@Service
|
||||
class ReportForms {
|
||||
private final DatabaseService db;
|
||||
private final ReportAdministration reports;
|
||||
private final FormAdministration forms;
|
||||
private final NewsletterTemplate templates;
|
||||
private static final Set<String> EDITABLE=Set.of("varchar","text","checkbox","phone_business","phone_private","email_business","email_private","date","birthdate","time","select:crvh:crvh_sid");
|
||||
record Save(String revision,String schemaRevision,String reportRevision,Map<Long,String> values) {}
|
||||
ReportForms(DatabaseService db,ReportAdministration reports,FormAdministration forms,NewsletterTemplate templates) {
|
||||
this.db=db;this.reports=reports;this.forms=forms;this.templates=templates;
|
||||
}
|
||||
private void category(long category) {if(category!=101&&category!=102)throw bad("Unbekanntes Berichtsformular.");}
|
||||
private List<Map<String,Object>> definitions(long category,long scope) {
|
||||
var defs=db.queryForList(MetafieldRecords.definitionsSql(category),scope);
|
||||
if(defs.isEmpty()||defs.size()>500)throw bad("Keine verwendbare Feldzuordnung für dieses Formular eingerichtet.");
|
||||
return defs;
|
||||
}
|
||||
private List<Map<String,Object>> raw(long report,List<Map<String,Object>> defs) {
|
||||
var args=new ArrayList<Object>(List.of(report));args.addAll(defs.stream().map(d->id(d,"id")).toList());
|
||||
return db.queryForList("SELECT mtfck_id,mtfv_id,mtfv_value FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfck_id,mtfv_value",args.toArray());
|
||||
}
|
||||
private static boolean editable(Map<String,Object> d) {return EDITABLE.contains(text(d,"type"));}
|
||||
private static boolean canEdit(LegacyUser u,Map<String,Object> report) {return id(report,"usr_id")==u.userId()||u.hasRight(3);}
|
||||
List<Map<String,Object>> options(LegacyUser u,ReportAdministration.Kind kind,long report) {
|
||||
reports.report(u,kind,report);
|
||||
return db.queryForList("SELECT mtfc_id AS id,mtfc_description AS label FROM metafieldcategory WHERE mtfc_id IN (101,102) ORDER BY mtfc_id");
|
||||
}
|
||||
Map<String,Object> get(LegacyUser u,ReportAdministration.Kind kind,long report,long category) {
|
||||
category(category);
|
||||
return db.readTransaction(()->{
|
||||
var owner=reports.report(u,kind,report);long scope=forms.fieldHeadquarters(u,category);
|
||||
var defs=definitions(category,scope);var rows=raw(report,defs);var stored=MetafieldRecords.values(rows);
|
||||
var template=one(db.queryForList("SELECT c.mtfc_description,t.mtft_content FROM metafieldcategory c LEFT JOIN metafieldtemplate t ON t.mtft_id=c.mtft_id WHERE c.mtfc_id=?",category));
|
||||
var computed=automatic(u,kind,owner,defs);var display=new LinkedHashMap<Long,String>(stored);display.putAll(computed);
|
||||
var schema=defs.stream().map(d->{var field=new LinkedHashMap<>(d);field.put("label",Jsoup.parse(text(d,"label")).text());field.put("editable",editable(d));return field;}).toList();
|
||||
var printDefs=defs.stream().map(d->{var field=new LinkedHashMap<>(d);if(text(d,"type").equals("sys:current_headquarters_company_logo"))field.put("type","image");return (Map<String,Object>)field;}).toList();
|
||||
var rendering=fields("data",fields("values",display),"definitions",printDefs,"template",text(template,"mtft_content"));
|
||||
String html=templates.render(rendering);
|
||||
var layoutValues=new LinkedHashMap<Long,String>(display);
|
||||
var layoutDefs=printDefs.stream().map(d->{var f=new LinkedHashMap<>(d);if(editable(d)){f.put("type","varchar");layoutValues.put(id(d,"id"),"NGFORMFIELD"+id(d,"id")+"END");}return (Map<String,Object>)f;}).toList();
|
||||
String layout=templates.render(fields("data",fields("values",layoutValues),"definitions",layoutDefs,"template",text(template,"mtft_content")));
|
||||
return fields("id",report,"category",category,"title",text(template,"mtfc_description"),"canEdit",canEdit(u,owner),"values",stored,"displayValues",display,
|
||||
"fields",schema,"revision",revision(rows),"schemaRevision",revision(defs),"reportRevision",revision(ReportAdministration.version(owner)),"html",html,"layout",layout);
|
||||
});
|
||||
}
|
||||
Map<String,Object> save(LegacyUser u,ReportAdministration.Kind kind,long report,long category,Save s) {
|
||||
category(category);if(s==null||s.values()==null||s.values().size()>500)throw bad("Formularwerte fehlen.");
|
||||
var owner=reports.report(u,kind,report);if(!canEdit(u,owner))throw new AccessDeniedException("Dieses Berichtsformular darf nur der Verfasser oder ein berechtigter Bearbeiter ändern.");
|
||||
unchanged(ReportAdministration.version(owner),s.reportRevision());
|
||||
long scope=forms.fieldHeadquarters(u,category);var defs=definitions(category,scope);unchanged(defs,s.schemaRevision());
|
||||
var keys=defs.stream().map(d->id(d,"id")).toList();if(!keys.containsAll(s.values().keySet()))throw bad("Unbekanntes Formularfeld.");
|
||||
var before=MetafieldRecords.values(raw(report,defs));var next=new LinkedHashMap<Long,String>();
|
||||
for(var d:defs) {
|
||||
long key=id(d,"id");String old=before.getOrDefault(key,"");String v=Objects.requireNonNullElse(s.values().getOrDefault(key,old),"");
|
||||
if(!editable(d)){if(!v.equals(old))throw bad(text(d,"label")+": Automatische Felder können nicht überschrieben werden.");continue;}
|
||||
if(!v.equals(old))validate(u,id(owner,"hq_id"),d,v);
|
||||
next.put(key,v);
|
||||
}
|
||||
db.reportFormWriteTransaction(()->{
|
||||
var currentOwner=reports.report(u,kind,report);if(!canEdit(u,currentOwner))throw new AccessDeniedException("Keine Bearbeitungsberechtigung.");
|
||||
unchanged(ReportAdministration.version(currentOwner),s.reportRevision());unchanged(definitions(category,scope),s.schemaRevision());
|
||||
var current=raw(report,defs);var currentValues=MetafieldRecords.values(current);
|
||||
// A lost acknowledgement may safely repeat exactly the already stored values.
|
||||
if(next.entrySet().stream().allMatch(e->e.getValue().equals(currentValues.getOrDefault(e.getKey(),""))))return null;
|
||||
unchanged(current,s.revision());
|
||||
for(var d:defs)if(editable(d)) {
|
||||
long key=id(d,"id");String v=next.get(key);if(v.equals(currentValues.getOrDefault(key,"")))continue;
|
||||
db.update("DELETE FROM metafieldvalue WHERE mtfck_id=? AND mtfv_id=?",key,report);
|
||||
db.update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",key,report,v);
|
||||
if(id(d,"history")==1)db.update("INSERT INTO metafieldvaluehistory (mtfck_id,mtfv_id,mtfv_value,mtfvh_createtime) VALUES (?,?,?,?)",key,report,v,LocalDateTime.now(ZoneId.of("Europe/Berlin")));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return fields("id",report);
|
||||
}
|
||||
private void validate(LegacyUser u,long hq,Map<String,Object> def,String value) {
|
||||
if(value.getBytes(StandardCharsets.UTF_8).length>65535)throw bad(text(def,"label")+": Eingabe ist zu lang.");
|
||||
String type=text(def,"type");if(value.isEmpty())return;
|
||||
switch(type) {
|
||||
case "checkbox" -> {if(!value.equals("1"))throw bad("Ungültiger Checkbox-Wert.");}
|
||||
case "date","birthdate" -> EvaluationService.parseDate(value);
|
||||
case "time" -> {if(!value.matches("(?:[01][0-9]|2[0-3]):[0-5][0-9]"))throw bad("Uhrzeit bitte als HH:MM eingeben.");}
|
||||
case "select:crvh:crvh_sid" -> {u.require(1);if(db.queryForList("SELECT cv.crvh_id FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid=?",hq,value).isEmpty())throw bad("Fahrzeug nicht in der Berichtsniederlassung gefunden.");}
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
List<Map<String,Object>> vehicles(LegacyUser u,ReportAdministration.Kind kind,long report,String query) {
|
||||
u.require(1);var owner=reports.report(u,kind,report);if(query==null||query.length()>100)throw bad("Suchbegriff ist zu lang.");
|
||||
return db.queryForList("SELECT cv.crvh_id AS id,cv.crvh_sid AS value,CONCAT_WS(' · ',cv.crvh_sid,cv.crvh_vh_sign) AS label FROM couriervehicle cv JOIN courier cr ON cr.cr_id=cv.cr_id WHERE cr.hq_id=? AND cv.crvh_sid LIKE ? ORDER BY cv.crvh_sid LIMIT 100",id(owner,"hq_id"),"%"+query.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")+"%");
|
||||
}
|
||||
List<Map<String,Object>> history(LegacyUser u,ReportAdministration.Kind kind,long report,long category) {
|
||||
category(category);reports.report(u,kind,report);var defs=definitions(category,forms.fieldHeadquarters(u,category));
|
||||
var args=new ArrayList<Object>(List.of(report));args.addAll(defs.stream().map(d->id(d,"id")).toList());
|
||||
return db.queryForList("SELECT mtfck_id AS fieldId,mtfv_value AS value,mtfvh_createtime AS time FROM metafieldvaluehistory WHERE mtfv_id=? AND mtfck_id IN ("+marks(defs.size())+") ORDER BY mtfvh_createtime DESC LIMIT 1000",args.toArray());
|
||||
}
|
||||
private Map<Long,String> automatic(LegacyUser u,ReportAdministration.Kind kind,Map<String,Object> report,List<Map<String,Object>> defs) {
|
||||
var result=new LinkedHashMap<Long,String>();
|
||||
var hq=one(db.queryForList("SELECT h.hq_id,h.hq_name,h.hq_mnemonic,c.cmp_comp,c.cmp_comp2,c.cmp_logo,c.cmp_logo_width,c.cmp_logo_height,c.cmp_hsno,a.ad_street,a.ad_zipcode,a.ad_city FROM headquarters h LEFT JOIN company c ON c.cmp_id=h.cmp_id LEFT JOIN address a ON a.ad_id=c.ad_id WHERE h.hq_id=?",u.headquartersId()));
|
||||
// Resolve only the report's actual object type; matching numerical IDs in another table convey no access.
|
||||
var allowed=reports.headquarters(u).stream().map(r->id(r,"id")).toList();
|
||||
var objectArgs=new ArrayList<Object>(List.of(id(report,"rp_objid")));objectArgs.addAll(allowed);
|
||||
var objects=db.queryForList("SELECT o."+kind.prefix+"_eid AS eid,o.hq_id,c.cmp_comp,c.cmp_comp2,c.cmp_hsno,a.ad_street,a.ad_zipcode,a.ad_city FROM "+kind.table+" o LEFT JOIN company c ON c.cmp_id=o.cmp_id LEFT JOIN address a ON a.ad_id=c.ad_id WHERE o."+kind.prefix+"_id=? AND o.hq_id IN ("+marks(allowed.size())+")",objectArgs.toArray());
|
||||
var object=objects.size()==1?objects.getFirst():Map.<String,Object>of();
|
||||
for(var d:defs) {
|
||||
String type=text(d,"type"),v="";boolean computed=type.startsWith("sys:")||type.startsWith("db:");if(!computed)continue;
|
||||
if(type.startsWith("sys:"))v=switch(type.substring(4)) {
|
||||
case "current_date" -> LocalDate.now(ZoneId.of("Europe/Berlin")).format(EvaluationService.DATE);
|
||||
case "current_headquarters_id" -> Long.toString(u.headquartersId());
|
||||
case "current_headquarters_name" -> text(hq,"hq_name");
|
||||
case "current_headquarters_mnemonic" -> text(hq,"hq_mnemonic");
|
||||
case "current_headquarters_company_name" -> text(hq,"cmp_comp");
|
||||
case "current_headquarters_company_name2" -> text(hq,"cmp_comp2");
|
||||
case "current_headquarters_company_logo" -> text(hq,"cmp_logo").isBlank()?"":text(hq,"cmp_logo")+","+(id(hq,"cmp_logo_width")>0?id(hq,"cmp_logo_width"):"")+","+(id(hq,"cmp_logo_height")>0?id(hq,"cmp_logo_height"):"");
|
||||
case "current_headquarters_street" -> text(hq,"ad_street");
|
||||
case "current_headquarters_housenumber" -> text(hq,"cmp_hsno");
|
||||
case "current_headquarters_zipcode" -> text(hq,"ad_zipcode");
|
||||
case "current_headquarters_city" -> text(hq,"ad_city");
|
||||
case "current_user" -> u.username();
|
||||
default -> "";
|
||||
};
|
||||
else if(type.startsWith("db:"+kind.prefix+":")&&!object.isEmpty()) {
|
||||
String field=type.substring(6);
|
||||
v=switch(field) {
|
||||
case "eid","courier_eid" -> text(object,"eid");case "company_name" -> text(object,"cmp_comp");case "company_name2" -> text(object,"cmp_comp2");
|
||||
case "street" -> text(object,"ad_street");case "housenumber" -> text(object,"cmp_hsno");case "zipcode" -> text(object,"ad_zipcode");case "city" -> text(object,"ad_city");
|
||||
case "stat_businessvolume_01","stat_businessvolume_02" -> kind==ReportAdministration.Kind.CUSTOMER?turnover(id(report,"rp_objid"),id(object,"hq_id"),field.endsWith("02")):"";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
result.put(id(d,"id"),v);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private String turnover(long customer,long hq,boolean previous) {
|
||||
LocalDate end=LocalDate.now(ZoneId.of("Europe/Berlin"));if(previous)end=end.minusYears(1);
|
||||
var sum=db.queryForObject("SELECT COALESCE(SUM(j.jb_totalprice),0) FROM job j JOIN costcenter cc ON cc.csc_id=j.csc_id_payer WHERE cc.cs_id=? AND j.hq_id=? AND j.jb_status=2 AND (j.jb_storno IS NULL OR j.jb_storno IN (0,1,3)) AND j.jb_finishtime>=? AND j.jb_finishtime<?",java.math.BigDecimal.class,customer,hq,end.withDayOfYear(1).atStartOfDay(),end.plusDays(1).atStartOfDay());
|
||||
return String.format(Locale.GERMANY,"%,.2f",sum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController
|
||||
@RequestMapping("/api/administration/reports/{kind}/{id}/forms")
|
||||
class ReportFormsController {
|
||||
private final ReportForms forms;
|
||||
ReportFormsController(ReportForms forms){this.forms=forms;}
|
||||
@GetMapping List<Map<String,Object>> options(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id){return forms.options(user(a),kind,id);}
|
||||
@GetMapping("/vehicles") List<Map<String,Object>> vehicles(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@RequestParam(defaultValue="")String q){return forms.vehicles(user(a),kind,id,q);}
|
||||
@GetMapping("/{category}") Map<String,Object> get(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@PathVariable long category){return forms.get(user(a),kind,id,category);}
|
||||
@PutMapping("/{category}") Map<String,Object> save(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@PathVariable long category,@RequestBody ReportForms.Save s){return forms.save(user(a),kind,id,category,s);}
|
||||
@GetMapping("/{category}/history") List<Map<String,Object>> history(Authentication a,@PathVariable ReportAdministration.Kind kind,@PathVariable long id,@PathVariable long category){return forms.history(user(a),kind,id,category);}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.util.*;
|
||||
import java.time.*;
|
||||
import java.math.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CrmScope.marks;
|
||||
@Service
|
||||
class SalesAnalytics {
|
||||
private final DatabaseService db;private final CrmScope scope;private final OrderEntryCatalog catalog;private final String logTable;
|
||||
SalesAnalytics(DatabaseService db,CrmScope scope,OrderEntryCatalog catalog,@Value("${app.history-schema:phoenix_log}")String schema){this.db=db;this.scope=scope;this.catalog=catalog;if(!schema.matches("[a-zA-Z0-9_]+"))throw new IllegalArgumentException("Ungültiges Historien-Schema");logTable="`"+schema+"`.log";}
|
||||
record Query(LocalDate from,LocalDate to,List<Long> headquarters){}
|
||||
record Target(long headquarters,String month,BigDecimal amount,String password,String revision){}
|
||||
record TargetAccess(long headquarters,String password){}
|
||||
Map<String,Object> options(LegacyUser u){return fields("headquarters",scope.headquarters(u),"currentHeadquarters",u.headquartersId(),"today",LocalDate.now(ZoneId.of("Europe/Berlin")),"motivationMode",catalog.parameter(0,"MOTIVATIONCOUNTER_MODE"),"previousMonth",catalog.enabled(0,"MOTIVATIONCOUNTER_PREVIOUS_MONTH"));}
|
||||
static Map<String,String> description(String text){var map=new HashMap<String,String>();for(String part:text.split("\\|")){int sep=part.indexOf('=');if(sep>0)map.put(part.substring(0,sep).strip(),part.substring(sep+1).strip());}return map;}
|
||||
static LocalDate previousYear(LocalDate date){var result=date.minusYears(1);return date.getMonthValue()==2&&date.getDayOfMonth()==28&&result.isLeapYear()?result.withDayOfMonth(29):result;}
|
||||
private Map<Long,BigDecimal> revenue(List<Long> hqs,LocalDate from,LocalDate to){var args=new ArrayList<Object>(hqs);args.add(from.atStartOfDay());args.add(to.plusDays(1).atStartOfDay());String payer=catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")?"jb.csc_id_related":"jb.csc_id_payer";var result=new HashMap<Long,BigDecimal>();for(var r:db.queryForList("SELECT cs.cs_id AS id,SUM(jb.jb_totalprice) AS amount FROM job jb JOIN costcenter csc ON csc.csc_id="+payer+" JOIN customer cs ON cs.cs_id=csc.cs_id JOIN company co ON co.cmp_id=cs.cmp_id WHERE jb.hq_id=cs.hq_id AND cs.hq_id IN ("+marks(hqs.size())+") AND jb.jb_status=2 AND (jb.jb_storno IS NULL OR jb.jb_storno IN (0,1,3)) AND jb.jb_finishtime>=? AND jb.jb_finishtime<? GROUP BY cs.cs_id",args.toArray()))result.put(id(r,"id"),amount(r,"amount"));return result;}
|
||||
Map<String,Object> switches(LegacyUser u,Query q){if(q==null)throw bad("Zeitraum fehlt.");range(q.from(),q.to());if(q.from().getYear()<1001||q.to().getYear()>9998)throw bad("Ungültiges Jahr.");var h=scope.checked(u,q.headquarters());return db.readTransaction(()->{
|
||||
var current=revenue(h,q.from(),q.to());var prior=revenue(h,previousYear(q.from()),previousYear(q.to()));var args=new ArrayList<Object>(h);args.addAll(h);args.add(q.from().atStartOfDay());args.add(q.to().plusDays(1).atStartOfDay());
|
||||
var logs=db.queryForList("SELECT l.usr_id AS userId,CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS person,h.hq_mnemonic AS headquarters,l.cs_id AS customerId,c.cs_eid AS customerNumber,co.cmp_comp AS company,l.log_createtime AS time,l.logo_description AS description FROM "+logTable+" l JOIN user u ON u.usr_id=l.usr_id JOIN headquarters h ON h.hq_id=l.hq_id JOIN customer c ON c.cs_id=l.cs_id JOIN company co ON co.cmp_id=c.cmp_id WHERE l.logo_id=95 AND l.hq_id IN ("+marks(h.size())+") AND c.hq_id IN ("+marks(h.size())+") AND l.log_createtime>=? AND l.log_createtime<? ORDER BY l.hq_id,person,l.log_createtime LIMIT 20001",args.toArray());if(logs.size()>20000)throw bad("Zu viele Statuswechsel. Bitte Zeitraum eingrenzen.");var conversions=new ArrayList<Map<String,Object>>();var summary=new LinkedHashMap<String,Map<String,Object>>();
|
||||
for(var l:logs){var d=description(text(l,"description"));String old=d.getOrDefault("OLD","");String src=d.getOrDefault("SRC","");if(!"0".equals(d.get("NEW"))||!Set.of("1","2","3").contains(old)||!Set.of("jb","cs").contains(src))continue;var row=new LinkedHashMap<>(l);row.remove("description");row.put("source",src.equals("jb")?"Auftrag":"Kundenstamm");row.put("previousStatus",switch(old){case "1"->"Interessent";case "2"->"Potentialkunde";default->"Wunschkunde";});row.put("revenue",current.getOrDefault(id(l,"customerId"),BigDecimal.ZERO));conversions.add(row);String key=id(l,"userId")+"/"+text(l,"headquarters");var person=summary.computeIfAbsent(key,k->fields("person",text(l,"person"),"headquarters",text(l,"headquarters"),"counts",new LinkedHashMap<String,Long>()));var counts=(Map<String,Long>)person.get("counts");counts.merge(src+old,1L,Long::sum);}
|
||||
var customers=db.queryForList("SELECT cs.cs_id AS customerId,cs.cs_eid AS customerNumber,co.cmp_comp AS company,CONCAT_WS(', ',u.usr_name,u.usr_firstname) AS person,u.usr_id AS userId,h.hq_mnemonic AS headquarters FROM customer cs JOIN company co ON co.cmp_id=cs.cmp_id JOIN employee e ON e.emp_id=co.emp_id_sales JOIN user u ON u.usr_id=e.usr_id JOIN headquarters h ON h.hq_id=cs.hq_id WHERE cs.hq_id IN ("+marks(h.size())+") ORDER BY h.hq_mnemonic,person,co.cmp_comp LIMIT 20001",h.toArray());if(customers.size()>20000)throw bad("Zu viele Kunden. Bitte Niederlassungen eingrenzen.");var growth=new ArrayList<Map<String,Object>>();for(var c:customers){long cid=id(c,"customerId");var row=new LinkedHashMap<>(c);BigDecimal now=current.getOrDefault(cid,BigDecimal.ZERO),before=prior.getOrDefault(cid,BigDecimal.ZERO);row.put("current",now);row.put("previous",before);row.put("growth",now.subtract(before));growth.add(row);summary.computeIfAbsent(id(c,"userId")+"/"+text(c,"headquarters"),k->fields("person",text(c,"person"),"headquarters",text(c,"headquarters"),"counts",Map.of()));}
|
||||
return fields("conversions",conversions,"summary",summary.values(),"growth",growth,"from",q.from(),"to",q.to(),"previousFrom",previousYear(q.from()),"previousTo",previousYear(q.to()));});}
|
||||
static YearMonth month(String value){try{var m=YearMonth.parse(value);if(m.getYear()<1000||m.getYear()>9998)throw bad("Ungültiger Monat.");return m;}catch(DateTimeException|NullPointerException e){throw bad("Monat bitte als JJJJ-MM angeben.");}}
|
||||
private static String targetKey(YearMonth m){return "MOTIVATIONCOUNTER_"+m.toString().replace('-','_');}
|
||||
Map<String,Object> motivation(LegacyUser u,String selected,List<Long> requested){var h=scope.checked(u,requested);var m=month(selected);var today=LocalDate.now(ZoneId.of("Europe/Berlin"));if(m.isAfter(YearMonth.from(today)))throw bad("Umsatzmonat liegt in der Zukunft.");LocalDate end=m.atEndOfMonth().isAfter(today)?today:m.atEndOfMonth();var args=new ArrayList<Object>(h);args.add(m.atDay(1).atStartOfDay());args.add(end.plusDays(1).atStartOfDay());String exclusion=catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS");var excluded=exclusion==null?List.<String>of():Arrays.stream(exclusion.split(",")).map(String::strip).filter(s->!s.isEmpty()).toList();if(excluded.size()>10000)throw bad("Zu viele ausgeschlossene Kunden im Parameter.");args.addAll(excluded);
|
||||
var amounts=db.queryForList("SELECT jb.hq_id AS id,COUNT(*) AS jobs,SUM(jb.jb_totalprice) AS amount FROM job jb JOIN costcenter csc ON csc.csc_id=jb.csc_id_payer JOIN customer cs ON cs.cs_id=csc.cs_id WHERE jb.hq_id IN ("+marks(h.size())+") AND jb.jb_status=2 AND (jb.jb_storno IS NULL OR jb.jb_storno IN (0,1,3)) AND jb.jb_finishtime>=? AND jb.jb_finishtime<?"+(excluded.isEmpty()?"":" AND cs.cs_eid NOT IN ("+marks(excluded.size())+")")+" GROUP BY jb.hq_id",args.toArray());
|
||||
var targets=new ArrayList<Object>(h);targets.add(targetKey(m));var targetRows=db.queryForList("SELECT hq_id,par_value FROM parameter WHERE emp_id=0 AND hq_id IN ("+marks(h.size())+") AND par_key=?",targets.toArray());var hqs=scope.headquarters(u);var rows=new ArrayList<Map<String,Object>>();for(long hq:h){var a=amounts.stream().filter(r->id(r,"id")==hq).findFirst().orElse(Map.of());var ts=targetRows.stream().filter(r->id(r,"hq_id")==hq).toList();if(ts.size()>1)throw bad("Mehrere Zielvorgaben für einen Monat. Bitte Parameter bereinigen.");BigDecimal target=ts.isEmpty()?BigDecimal.ZERO:amount(ts.getFirst(),"par_value"),actual=amount(a,"amount");rows.add(fields("id",hq,"label",hqs.stream().filter(r->id(r,"id")==hq).map(r->text(r,"label")).findFirst().orElse(""),"jobs",id(a,"jobs"),"actual",actual,"target",target,"remaining",target.subtract(actual),"percent",percent(actual,target)));}
|
||||
return fields("month",m.toString(),"through",end,"items",rows);}
|
||||
static BigDecimal amount(Map<String,Object> row,String key){try{var amount=decimal(row,key);return amount==null?BigDecimal.ZERO:amount;}catch(NumberFormatException e){throw bad("Ungültiger Betrag im Bestandsparameter.");}}
|
||||
static BigDecimal percent(BigDecimal actual,BigDecimal target){return target.signum()<=0?null:actual.multiply(BigDecimal.valueOf(100)).divide(target,2,RoundingMode.HALF_UP);}
|
||||
private final Map<Long,ArrayDeque<Instant>> failures=new HashMap<>();
|
||||
private void password(LegacyUser u,long hq,String supplied){scope.checked(u,List.of(hq));synchronized(failures){var attempts=failures.computeIfAbsent(u.userId(),k->new ArrayDeque<>());Instant cutoff=Instant.now().minusSeconds(300);while(!attempts.isEmpty()&&attempts.peekFirst().isBefore(cutoff))attempts.removeFirst();if(attempts.size()>=5)throw bad("Zu viele Passwortversuche. Bitte fünf Minuten warten.");String expected=catalog.parameter(hq,"MOTIVATIONCOUNTER_PASSWORD");if(expected==null||expected.isBlank())throw bad("Für diese Niederlassung ist kein Motivation-Counter-Passwort eingerichtet.");if(supplied==null||supplied.length()>500||!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8))){attempts.addLast(Instant.now());throw new AccessDeniedException("Passwort für Zielvorgaben ist nicht korrekt.");}failures.remove(u.userId());}}
|
||||
List<Map<String,Object>> targets(LegacyUser u,TargetAccess s){if(s==null)throw bad("Zielvorgaben-Anfrage fehlt.");password(u,s.headquarters(),s.password());return targetList(s.headquarters());}
|
||||
private List<Map<String,Object>> targetList(long hq){return db.queryForList("SELECT par_id AS id,par_key AS name,par_value AS amount FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key REGEXP '^MOTIVATIONCOUNTER_[0-9]{4}_[0-9]{2}$' ORDER BY par_key DESC",hq).stream().<Map<String,Object>>map(r->fields("month",text(r,"name").substring(18).replace('_','-'),"amount",text(r,"amount"),"revision",revision(r))).toList();}
|
||||
Map<String,Object> target(LegacyUser u,Target s){if(s==null)throw bad("Zielvorgabe fehlt.");var m=month(s.month());if(m.isBefore(YearMonth.now(ZoneId.of("Europe/Berlin"))))throw bad("Zielvorgaben vergangener Monate können nicht verändert werden.");if(s.amount()==null||s.amount().signum()<0||s.amount().scale()>2||s.amount().compareTo(new BigDecimal("999999999.99"))>0)throw bad("Betrag bitte mit höchstens zwei Nachkommastellen angeben.");password(u,s.headquarters(),s.password());return db.writeTransaction(()->{one(db.lockRows("SELECT hq_id FROM headquarters WHERE hq_id=?",s.headquarters()));var rows=db.lockRows("SELECT par_id AS id,par_key AS name,par_value AS amount FROM parameter WHERE emp_id=0 AND hq_id=? AND par_key=?",s.headquarters(),targetKey(m));if(rows.size()>1)throw bad("Doppelte Zielvorgabe im Bestand.");if(rows.isEmpty()){if(s.revision()!=null&&!s.revision().isEmpty())throw bad("Zielvorgabe wurde entfernt. Bitte neu laden.");db.insert("parameter",fields("par_key",targetKey(m),"md_id",0,"hq_id",s.headquarters(),"emp_id",0,"par_value",s.amount().toPlainString(),"par_text",""));}else{unchanged(rows.getFirst(),s.revision());db.update("UPDATE parameter SET par_value=? WHERE par_id=?",s.amount().toPlainString(),id(rows.getFirst(),"id"));}return fields("saved",true);});}
|
||||
}
|
||||
@@ -26,8 +26,9 @@ import java.util.List;
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
public class SecurityConfig {
|
||||
@Bean
|
||||
SecurityFilterChain security(HttpSecurity http, LegacyAuthenticationProvider provider, ObjectMapper json) throws Exception {
|
||||
SecurityFilterChain security(HttpSecurity http, LegacyAuthenticationProvider provider, ObjectMapper json, OrderEntryHeadquarters headquarters) throws Exception {
|
||||
return http.authenticationProvider(provider)
|
||||
.addFilterAfter(new OrderEntryScopeFilter(headquarters,json), org.springframework.security.web.access.intercept.AuthorizationFilter.class)
|
||||
.csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository()))
|
||||
.cors(cors -> {})
|
||||
// Authorization belongs to any upstream Basic authentication, not to VoTiAn.
|
||||
@@ -89,7 +90,7 @@ public class SecurityConfig {
|
||||
CorsConfigurationSource cors(@Value("${app.cors-origins}") String origins) {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(Arrays.stream(origins.split(",")).map(String::trim).toList());
|
||||
config.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
|
||||
config.setExposedHeaders(List.of("Content-Disposition", "X-Total-Count", "X-Result-Limit"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Mode 1 price/benefit types. InnoDB edits and an audit outbox commit together. */
|
||||
@Service
|
||||
class ServiceDefinitionAdministration {
|
||||
enum Kind { PRICE("service","srv"), BENEFIT("servicetype","srvt"); final String table,p;Kind(String table,String p){this.table=table;this.p=p;} }
|
||||
record Change(String requestId,ServicePriceAdministration.Query query,Kind kind,long id,String revision,String name,boolean remove,List<Long> groups) {}
|
||||
record Receipt(String hash,long id,long user,long hq,int audit,String description,String at) {}
|
||||
final DatabaseService db;final ServicePriceAdministration prices;final ObjectMapper json;final String log;
|
||||
ServiceDefinitionAdministration(DatabaseService db,ServicePriceAdministration prices,ObjectMapper json,@Value("${app.history-schema:phoenix_log}")String history){this.db=db;this.prices=prices;this.json=json;if(!history.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");log="`"+history+"`.log";}
|
||||
long access(LegacyUser u,ServicePriceAdministration.Query q){long hq=prices.scope(u,q);if(q.mode()!=1)throw bad("Preis- und Leistungstypen können im Modus 1 verwaltet werden.");return hq;}
|
||||
Map<String,Object> options(LegacyUser u,ServicePriceAdministration.Query q){long hq=access(u,q);return fields("prices",list(q,hq,Kind.PRICE),"benefits",list(q,hq,Kind.BENEFIT),"canDelete",u.hasRight(31),"groups",groupOptions(u,hq),"canGroup",q.customer()==0&&u.hasRight(8)&&u.hasRight(25)&&(hq>0||u.hasRight(10)),"pending",pending(u));}
|
||||
List<Map<String,Object>> pending(LegacyUser u){return db.queryForList("SELECT SUBSTRING(gdc_gen_fieldname,8) AS token FROM genericdatacontainer WHERE gdc_obj_type='srvdef' AND gdc_obj_id=? AND LEFT(gdc_gen_fieldname,7)='ng_def_' AND gdc_context='PENDING_LOG' ORDER BY gdc_id",u.userId());}
|
||||
List<Map<String,Object>> groupOptions(LegacyUser u,long hq){if(!u.hasRight(8)||!u.hasRight(25)||(hq==0&&!u.hasRight(10)))return List.of();return db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) AND grp_readonly=0 AND grp_system=0 ORDER BY grp_name,grp_id",mandator(db,u),u.headquartersId());}
|
||||
String select(Kind k){return "SELECT "+k.p+"_id AS id,"+k.p+"_name AS name,"+k.p+"_group AS groups FROM "+k.table+" WHERE hq_id=? AND cs_id=? AND "+k.p+"_mode=1";}
|
||||
List<Map<String,Object>> list(ServicePriceAdministration.Query q,long hq,Kind k){return db.queryForList(select(k)+" ORDER BY "+k.p+"_name,"+k.p+"_id",hq,q.customer()).stream().<Map<String,Object>>map(r->fields("id",id(r,"id"),"name",text(r,"name"),"groups",GroupAdministration.memberships(text(r,"groups")),"revision",revision(r))).toList();}
|
||||
Receipt decode(Map<String,Object> r){try{return json.readValue(text(r,"gdc_content"),Receipt.class);}catch(Exception e){throw new IllegalStateException("Gesicherter Tarifvorgang ist nicht lesbar.",e);}}
|
||||
Map<String,Object> receipt(LegacyUser u,String token){var rows=db.lockRows("SELECT gdc_id,gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='srvdef' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),"ng_def_"+token);if(rows.size()>1)throw bad("Mehrdeutige Vorgangskennung.");return rows.isEmpty()?null:rows.getFirst();}
|
||||
Map<String,Object> change(LegacyUser u,Change c){prices.access(u);if(c==null||c.kind()==null||c.id()<0)throw bad("Ungültiger Preis-/Leistungstyp.");long hq=access(u,c.query());if(c.remove()){u.require(31);if(c.id()==0)throw bad("Zu löschender Typ fehlt.");}
|
||||
String token=FilterAdministration.token(c.requestId()),hash=revision(prices.encode(c));String name=c.remove()?"":required(c.name(),50,"Bezeichnung");
|
||||
db.writeTransaction(()->{db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id");var prior=receipt(u,token);if(prior!=null){if(!decode(prior).hash().equals(hash))throw bad("Vorgangskennung gehört zu einer anderen Änderung.");return null;}
|
||||
// Recheck scope inside the transaction and lock the exact owned catalog row.
|
||||
if(access(u,c.query())!=hq)throw bad("Die Tarifzuordnung wurde geändert. Bitte neu laden.");var k=c.kind();var before=c.id()==0?Map.<String,Object>of():one(db.lockRows(select(k)+" AND "+k.p+"_id=?",hq,c.query().customer(),c.id()));if(c.id()>0)unchanged(before,c.revision());
|
||||
String groups=text(before,"groups");
|
||||
if(c.groups()!=null&&!c.remove()){
|
||||
var desired=new LinkedHashSet<>(c.groups());if(desired.contains(null))throw bad("Ungültige Gruppenauswahl.");var original=GroupAdministration.memberships(groups);
|
||||
if(!desired.equals(original)){u.require(8);u.require(25);if(hq==0)u.require(10);if(c.query().customer()!=0)throw bad("Gruppen werden bei allgemeinen Preis- und Leistungstypen verwaltet.");var allowed=groupOptions(u,hq).stream().map(r->id(r,"id")).toList();var difference=new HashSet<>(desired);difference.addAll(original);var same=new HashSet<>(desired);same.retainAll(original);difference.removeAll(same);if(!allowed.containsAll(difference))throw bad("Eine Gruppe ist geschützt oder nicht freigegeben.");groups=desired.isEmpty()?"":","+String.join(",",desired.stream().sorted().map(String::valueOf).toList())+",";if(groups.length()>255)throw bad("Zu viele Gruppenzuordnungen.");}
|
||||
}
|
||||
if(!c.remove()&&!db.queryForList("SELECT "+k.p+"_id FROM "+k.table+" WHERE hq_id=? AND cs_id=? AND "+k.p+"_mode=1 AND "+k.p+"_name=? AND "+k.p+"_id<>?",hq,c.query().customer(),name,c.id()).isEmpty())throw bad("Die Bezeichnung existiert bereits.");
|
||||
long id=c.id();int audit;
|
||||
if(c.remove()){db.update("DELETE FROM "+k.table+" WHERE "+k.p+"_id=? AND hq_id=? AND cs_id=? AND "+k.p+"_mode=1",id,hq,c.query().customer());audit=k==Kind.PRICE?131:132;}
|
||||
else if(id==0){id=db.insert(k.table,fields(k.p+"_mode",1,"hq_id",hq,"cs_id",c.query().customer(),k.p+"_name",name,k.p+"_group",groups));audit=k==Kind.PRICE?58:60;}
|
||||
else{db.update("UPDATE "+k.table+" SET "+k.p+"_name=?,"+k.p+"_group=? WHERE "+k.p+"_id=?",name,groups,id);audit=k==Kind.PRICE?57:59;}
|
||||
String desc="DBOP="+(c.remove()?"Delete":c.id()==0?"Insert":"Update")+"_"+k.table+"|SRV_MODE=1|"+k.p.toUpperCase(Locale.ROOT)+"_ID="+id+"|NAME="+(c.remove()?text(before,"name"):name)+"|NG="+token;
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","srvdef","gdc_obj_id",u.userId(),"gdc_gen_fieldname","ng_def_"+token,"gdc_content",prices.encode(new Receipt(hash,id,u.userId(),u.headquartersId(),audit,desc,LocalDateTime.now(ZoneId.of("Europe/Berlin")).toString())),"gdc_context","PENDING_LOG"));return null;
|
||||
});return resume(u,token);
|
||||
}
|
||||
Map<String,Object> resume(LegacyUser u,String token){prices.access(u);FilterAdministration.token(token);
|
||||
try{return db.writeTransaction(()->{db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id");var row=receipt(u,token);if(row==null)throw bad("Tarifvorgang nicht gefunden.");var r=decode(row);if(text(row,"gdc_context").equals("DONE"))return fields("saved",true,"id",r.id(),"state","DONE");
|
||||
if(db.queryForList("SELECT log_id FROM "+log+" WHERE logo_id=? AND logo_description=? LIMIT 1",r.audit(),r.description()).isEmpty())db.update("INSERT INTO "+log+" (logo_id,hq_id,jb_id,usr_id,cr_id,cr_sid,cs_id,at_id,pt_id,emp_id,logo_description,log_createtime) VALUES (?,?,0,?,0,'',0,0,0,0,?,?)",r.audit(),r.hq(),r.user(),r.description(),LocalDateTime.parse(r.at()));
|
||||
db.update("UPDATE genericdatacontainer SET gdc_context='DONE' WHERE gdc_id=?",id(row,"gdc_id"));return fields("saved",true,"id",r.id(),"state","DONE");});
|
||||
}catch(org.springframework.dao.DataAccessException e){return fields("saved",true,"state","PENDING_LOG","message","Änderung gespeichert. Der Protokolleintrag steht noch aus und kann erneut übernommen werden.");}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Service matrices from admin/services.php; history is the source for effective dated tariffs. */
|
||||
@Service
|
||||
class ServicePriceAdministration {
|
||||
record Query(int mode,long customer,LocalDate date) {}
|
||||
record Cell(long service,long type,String value,String courier) {}
|
||||
record Save(String requestId,Query query,String revision,boolean discount,List<Cell> cells) {}
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final ObjectMapper json;
|
||||
ServicePriceAdministration(DatabaseService db,OrderEntryCatalog catalog,ObjectMapper json){this.db=db;this.catalog=catalog;this.json=json;}
|
||||
LocalDateTime now(){return LocalDateTime.now(ZoneId.of("Europe/Berlin"));}
|
||||
LocalDate today(){return now().toLocalDate();}
|
||||
void access(LegacyUser u){u.require(2);}
|
||||
boolean enabled(long hq,String key){String v=Objects.requireNonNullElse(catalog.parameter(hq,key),"");return !v.isEmpty()&&!v.equals("0");}
|
||||
Map<String,Object> options(LegacyUser u){access(u);return fields("today",today().toString(),"defaultMode",enabled(u.headquartersId(),"SERVICE_DISPLAY_MODE_DEFAULT")?1:0,"general",u.hasRight(29),"customers",u.hasRight(0));}
|
||||
List<Map<String,Object>> customers(LegacyUser u,String q){access(u);u.require(0);String term="%"+AddressAdministration.like(AddressAdministration.clean(q,80));return db.queryForList("SELECT cs.cs_id AS id,CONCAT_WS(' · ',cs.cs_eid,c.cmp_comp,c.cmp_comp2) AS label FROM customer cs JOIN company c ON c.cmp_id=cs.cmp_id WHERE cs.hq_id=? AND COALESCE(c.cmp_archived,0)=0 AND (cs.cs_eid LIKE ? ESCAPE '!' OR c.cmp_comp LIKE ? ESCAPE '!') ORDER BY cs.cs_eid,cs.cs_id LIMIT 100",u.headquartersId(),term,term);}
|
||||
long scope(LegacyUser u,Query q){access(u);if(q==null||q.mode()<0||q.mode()>1||q.customer()<0)throw bad("Ungültige Tarifauswahl.");
|
||||
if(q.customer()==0)u.require(29);else{u.require(0);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",q.customer(),u.headquartersId()));}
|
||||
if(q.date()==null||q.date().isBefore(today())||q.date().isAfter(LocalDate.of(today().getYear()+1,12,31))||(q.mode()==1&&!q.date().equals(today())))throw bad("Für Modus 0 bitte heute oder ein zukünftiges Datum bis Ende des nächsten Jahres wählen. Modus 1 gilt ab heute.");
|
||||
return enabled(u.headquartersId(),q.mode()==0?"MANDATOR_SERVICE_ENABLED":"MANDATOR_SERVICE2_ENABLED")?u.headquartersId():0;
|
||||
}
|
||||
List<Map<String,Object>> definitions(LegacyUser u,Query q,long hq,boolean type,boolean lock){
|
||||
String table=type?"servicetype":"service",p=type?"srvt":"srv";
|
||||
String sql="SELECT d."+p+"_id AS id,d."+p+"_name AS name,d."+p+"_group AS groups,d.cs_id AS customer"+(type?"":",d.srv_sort AS sort")+" FROM "+table+" d WHERE d.hq_id=? AND d."+p+"_mode=? AND ";
|
||||
List<Object> args=new ArrayList<>(List.of(hq,q.mode()));
|
||||
if(q.mode()==1&&q.customer()>0){sql+="(d.cs_id=? OR (d.cs_id=0 AND EXISTS (SELECT 1 FROM customer"+table+" a WHERE a."+p+"_id=d."+p+"_id AND a.cs_id=?)))";args.add(q.customer());args.add(q.customer());}else sql+="d.cs_id=0";
|
||||
sql+=" ORDER BY d."+p+"_group,"+(type?"d.srvt_name":"d.srv_sort")+",d."+p+"_id";
|
||||
var rows=lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());
|
||||
if(!type&&q.mode()==0){String rights=catalog.parameter(u.headquartersId(),"RIGHTS_TABLE_SERVICE");if(rights.isEmpty())rights=catalog.parameter(0,"RIGHTS_TABLE_SERVICE");var denied=new HashSet<String>();for(String entry:rights.split("\\|")){String[] part=entry.split("=",2);if(part.length==2&&part[1].strip().matches("[0-9]{1,3}")&&!u.hasRight(Integer.parseInt(part[1].strip())))denied.add(part[0].strip());}return rows.stream().filter(r->!denied.contains(text(r,"name"))).toList();}
|
||||
return rows;
|
||||
}
|
||||
static String key(long s,long t){return s+":"+t;}
|
||||
static BigDecimal amount(Object v){return v==null?BigDecimal.ZERO.setScale(2):new BigDecimal(v.toString()).setScale(2,RoundingMode.HALF_UP);}
|
||||
static BigDecimal input(String v,boolean discount){String s=Objects.requireNonNullElse(v,"").strip().replace(',','.');if(s.isEmpty())s="0";if(!s.matches("-?[0-9]{1,5}(\\.[0-9]{1,2})?"))throw bad("Beträge bitte mit höchstens zwei Nachkommastellen eingeben.");BigDecimal n=new BigDecimal(s).setScale(2);if(n.abs().compareTo(new BigDecimal(discount?"999.99":"99999.99"))>0)throw bad("Der Wert überschreitet den Wertebereich der Tarifdatenbank.");return n;}
|
||||
static List<String> values(Map<String,Object> row){return List.of("price","discount","courier","courierDiscount").stream().map(k->amount(row.get(k)).toPlainString()).toList();}
|
||||
record State(long hq,List<Map<String,Object>> columns,List<Map<String,Object>> rows,List<Map<String,Object>> current,List<Map<String,Object>> history,List<Map<String,Object>> cells,String revision) {}
|
||||
State state(LegacyUser u,Query q,boolean lock){long hq=scope(u,q);var cols=definitions(u,q,hq,false,lock);var rows=definitions(u,q,hq,true,lock);
|
||||
String base=" WHERE hq_id=? AND cs_id=? AND srv_id IN (SELECT srv_id FROM service WHERE hq_id=? AND srv_mode=?) AND srvt_id IN (SELECT srvt_id FROM servicetype WHERE hq_id=? AND srvt_mode=?)";
|
||||
Object[] params={hq,q.customer(),hq,q.mode(),hq,q.mode()};
|
||||
String currentSql="SELECT srv_id AS service,srvt_id AS type,srvc_price AS price,srvc_discount AS discount,srvc_cr_price AS courier,srvc_cr_discount AS courierDiscount FROM servicecustomer"+base+" ORDER BY srv_id,srvt_id";
|
||||
var current=lock?db.lockRows(currentSql,params):db.queryForList(currentSql,params);
|
||||
String historySql="SELECT srv_id AS service,srvt_id AS type,srvh_price AS price,srvh_discount AS discount,srvh_cr_price AS courier,srvh_cr_discount AS courierDiscount,srvh_validfrom AS validFrom FROM servicehistory"+base;
|
||||
boolean future=q.date().isAfter(today());var hp=new ArrayList<>(Arrays.asList(params));
|
||||
if(future){historySql+=" AND srvh_validfrom>=? AND srvh_validfrom<?";hp.add(q.date().atStartOfDay());hp.add(q.date().plusDays(1).atStartOfDay());}
|
||||
else {historySql+=" AND srvh_validfrom<=?";hp.add(now());}
|
||||
historySql+=" ORDER BY srv_id,srvt_id,srvh_validfrom DESC";
|
||||
var history=lock?db.lockRows(historySql,hp.toArray()):db.queryForList(historySql,hp.toArray());
|
||||
Map<String,List<Map<String,Object>>> currentBy=new HashMap<>(),historyBy=new HashMap<>();
|
||||
for(var r:current)currentBy.computeIfAbsent(key(id(r,"service"),id(r,"type")),k->new ArrayList<>()).add(r);
|
||||
for(var r:history)historyBy.computeIfAbsent(key(id(r,"service"),id(r,"type")),k->new ArrayList<>()).add(r);
|
||||
var cells=new ArrayList<Map<String,Object>>();
|
||||
for(var row:rows)for(var col:cols){long s=id(col,"id"),t=id(row,"id");var c=currentBy.getOrDefault(key(s,t),List.of());var h=historyBy.getOrDefault(key(s,t),List.of());
|
||||
boolean ambiguous=c.size()>1||(!h.isEmpty()&&h.stream().filter(v->Objects.equals(v.get("validFrom"),h.getFirst().get("validFrom"))).count()>1)||(future&&h.size()>1);
|
||||
var effective=!h.isEmpty()?h.getFirst():!future&&!c.isEmpty()?c.getFirst():Map.<String,Object>of();var v=values(effective);
|
||||
cells.add(fields("service",s,"type",t,"price",v.get(0),"discount",v.get(1),"courier",v.get(2),"courierDiscount",v.get(3),"ambiguous",ambiguous));
|
||||
}
|
||||
return new State(hq,cols,rows,current,history,cells,revision(fields("hq",hq,"query",q,"columns",cols,"rows",rows,"cells",cells,"current",current,"history",history)));
|
||||
}
|
||||
Map<String,Object> grid(LegacyUser u,Query q){access(u);return db.readTransaction(()->{var s=state(u,q,false);return fields("columns",s.columns(),"rows",s.rows(),"cells",s.cells(),"revision",s.revision(),"scope",s.hq(),"futureDates",db.queryForList("SELECT DISTINCT DATE(srvh_validfrom) AS date FROM servicehistory WHERE hq_id=? AND cs_id=? AND srvh_validfrom>CURRENT_DATE() ORDER BY date",s.hq(),q.customer()),"future",q.date().isAfter(today()));});}
|
||||
String encode(Object v){try{return json.writeValueAsString(v);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
Map<String,Object> save(LegacyUser u,Save request){access(u);if(request==null||request.cells()==null||request.cells().isEmpty()||request.cells().size()>2000)throw bad("Bitte 1 bis 2000 geänderte Tarifzellen übergeben.");scope(u,request.query());String token=FilterAdministration.token(request.requestId()),hash=revision(encode(request));
|
||||
var seen=new HashSet<String>();for(var c:request.cells()){if(c==null||!seen.add(key(c.service(),c.type())))throw bad("Tarifzellen wurden doppelt übergeben.");input(c.value(),request.discount());input(c.courier(),request.discount());}
|
||||
return db.writeTransaction(()->{
|
||||
// Global definitions can be shared between headquarters: one deterministic lock order.
|
||||
db.lockRows("SELECT hq_id FROM headquarters ORDER BY hq_id");
|
||||
var receipts=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='srvprice' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),"ng_price_"+token);
|
||||
if(!receipts.isEmpty()){if(receipts.size()!=1||!text(receipts.getFirst(),"gdc_content").equals(hash))throw bad("Vorgangskennung gehört zu einer anderen Änderung.");return fields("saved",true,"repeated",true);}
|
||||
var q=request.query();var s=state(u,q,true);if(!s.revision().equals(request.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Tarife wurden zwischenzeitlich geändert. Bitte neu laden und die Änderungen vergleichen.");
|
||||
boolean future=q.date().isAfter(today());int count=0;
|
||||
for(var changed:request.cells()){
|
||||
var old=s.cells().stream().filter(c->id(c,"service")==changed.service()&&id(c,"type")==changed.type()).findFirst().orElseThrow(()->bad("Tarifzelle ist nicht freigegeben."));
|
||||
if(Boolean.TRUE.equals(old.get("ambiguous")))throw bad("Für diese Tarifzelle gibt es mehrere widersprüchliche Bestandszeilen. Bitte zuerst im Bestand klären.");
|
||||
var price=amount(old.get("price"));var discount=amount(old.get("discount"));var courier=amount(old.get("courier"));var crDiscount=amount(old.get("courierDiscount"));
|
||||
var value=input(changed.value(),request.discount());var cr=input(changed.courier(),request.discount());
|
||||
if(value.compareTo(request.discount()?discount:price)==0&&cr.compareTo(request.discount()?crDiscount:courier)==0)continue;
|
||||
if(request.discount()){discount=value;crDiscount=cr;}else{price=value;courier=cr;}
|
||||
Object[] key={changed.service(),changed.type(),s.hq(),q.customer()};String where="srv_id=? AND srvt_id=? AND hq_id=? AND cs_id=?";
|
||||
if(!future){boolean exists=s.current().stream().anyMatch(c->id(c,"service")==changed.service()&&id(c,"type")==changed.type());
|
||||
if(exists)db.update("UPDATE servicecustomer SET srvc_price=?,srvc_discount=?,srvc_cr_price=?,srvc_cr_discount=? WHERE "+where,price,discount,courier,crDiscount,key[0],key[1],key[2],key[3]);
|
||||
else db.update("INSERT INTO servicecustomer (srv_id,srvt_id,hq_id,cs_id,srvc_price,srvc_discount,srvc_cr_price,srvc_cr_discount) VALUES (?,?,?,?,?,?,?,?)",key[0],key[1],key[2],key[3],price,discount,courier,crDiscount);
|
||||
}
|
||||
var existing=future?s.history().stream().filter(c->id(c,"service")==changed.service()&&id(c,"type")==changed.type()).toList():List.<Map<String,Object>>of();
|
||||
if(!existing.isEmpty())db.update("UPDATE servicehistory SET srvh_price=?,srvh_discount=?,srvh_cr_price=?,srvh_cr_discount=?,usr_id=? WHERE "+where+" AND srvh_validfrom=?",price,discount,courier,crDiscount,u.userId(),key[0],key[1],key[2],key[3],existing.getFirst().get("validFrom"));
|
||||
else db.update("INSERT INTO servicehistory (srv_id,srvt_id,hq_id,cs_id,usr_id,srvh_validfrom,srvh_price,srvh_discount,srvh_cr_price,srvh_cr_discount) VALUES (?,?,?,?,?,?,?,?,?,?)",key[0],key[1],key[2],key[3],u.userId(),future?q.date().atStartOfDay():nextTimestamp(s,changed),price,discount,courier,crDiscount);
|
||||
count++;
|
||||
}
|
||||
db.insert("genericdatacontainer",fields("gdc_obj_type","srvprice","gdc_obj_id",u.userId(),"gdc_gen_fieldname","ng_price_"+token,"gdc_content",hash,"gdc_context","DONE"));return fields("saved",true,"changed",count);
|
||||
});
|
||||
}
|
||||
LocalDateTime nextTimestamp(State s,Cell c){LocalDateTime now=now().withNano(0);for(var h:s.history())if(id(h,"service")==c.service()&&id(h,"type")==c.type()&&now.equals(dateTime(h,"validFrom")))throw bad("Dieser Tarif wurde gerade gespeichert. Bitte nach einer Sekunde neu laden und erneut speichern.");return now;}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController @RequestMapping("/api/administration/service-prices")
|
||||
class ServicePriceController {
|
||||
private final ServicePriceAdministration service;
|
||||
private final ServiceDefinitionAdministration definitions;
|
||||
ServicePriceController(ServicePriceAdministration service,ServiceDefinitionAdministration definitions){this.service=service;this.definitions=definitions;}
|
||||
@GetMapping Map<String,Object> options(Authentication a){return service.options(user(a));}
|
||||
@GetMapping("/customers") List<Map<String,Object>> customers(Authentication a,@RequestParam(defaultValue="") String q){return service.customers(user(a),q);}
|
||||
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody ServicePriceAdministration.Query q){return service.grid(user(a),q);}
|
||||
@PutMapping Map<String,Object> save(Authentication a,@RequestBody ServicePriceAdministration.Save s){return service.save(user(a),s);}
|
||||
@PostMapping("/definitions/query") Map<String,Object> definitions(Authentication a,@RequestBody ServicePriceAdministration.Query q){return definitions.options(user(a),q);}
|
||||
@PutMapping("/definitions") Map<String,Object> definition(Authentication a,@RequestBody ServiceDefinitionAdministration.Change c){return definitions.change(user(a),c);}
|
||||
@PostMapping("/definitions/{token}/resume") Map<String,Object> resume(Authentication a,@PathVariable String token){return definitions.resume(user(a),token);}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
|
||||
/** Paginated PDF statement, using a standard embedded encoding and no external resources. */
|
||||
final class StatementPdf {
|
||||
static final Charset WIN=Charset.forName("windows-1252");
|
||||
private StatementPdf(){}
|
||||
static byte[] create(Map<String,Object> data,BillingService.Query query){
|
||||
var lines=new ArrayList<String>();lines.add(query.mode().equals("COURIER")?"Kurierabrechnung / Leistungsübersicht":"Rechnungsanlage / Leistungsübersicht");lines.add(query.from()+" bis "+query.to());lines.add("");
|
||||
for(var row:(List<Map<String,Object>>)data.get("rows")){
|
||||
lines.add("Auftrag "+text(row,"id")+" Rechnung "+text(row,"invoice")+" Kurier "+text(row,"courier"));
|
||||
wrap(lines,text(row,"customer")+" / "+text(row,"centerName"));
|
||||
lines.add("Erledigt: "+text(row,"finished")+" Referenz: "+text(row,"reference"));
|
||||
lines.add("Netto "+text(row,"net")+" EUR MwSt "+text(row,"tax")+" EUR Brutto "+text(row,"gross")+" EUR");
|
||||
wrap(lines,org.jsoup.Jsoup.parse(text(row,"description")).text());lines.add("");
|
||||
}
|
||||
lines.add("Summe netto: "+data.get("net")+" EUR");lines.add("Summe MwSt: "+data.get("tax")+" EUR");lines.add("Summe brutto: "+data.get("gross")+" EUR");
|
||||
var wrapped=new ArrayList<String>();for(String line:lines){if(line.isEmpty())wrapped.add("");else wrap(wrapped,line);}return render(wrapped);
|
||||
}
|
||||
static void wrap(List<String> lines,String text){for(String paragraph:text.replaceAll("[\\p{Cntrl}&&[^\\n]]"," ").split("\\n")){while(paragraph.length()>92){int at=paragraph.lastIndexOf(' ',92);if(at<20)at=92;lines.add(paragraph.substring(0,at));paragraph=paragraph.substring(at).stripLeading();}if(!paragraph.isEmpty())lines.add(paragraph);}}
|
||||
static byte[] render(List<String> lines){
|
||||
int pages=Math.max(1,(lines.size()+49)/50);var objects=new ArrayList<byte[]>();objects.add(bytes("<< /Type /Catalog /Pages 2 0 R >>"));var children=new StringBuilder();for(int i=0;i<pages;i++)children.append(4+2*i).append(" 0 R ");objects.add(bytes("<< /Type /Pages /Kids ["+children+"] /Count "+pages+" >>"));objects.add(bytes("<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"));
|
||||
for(int i=0;i<pages;i++){objects.add(bytes("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents "+(5+2*i)+" 0 R >>"));StringBuilder content=new StringBuilder("BT /F1 9 Tf 40 800 Td 14 TL\n");for(int n=i*50;n<Math.min((i+1)*50,lines.size());n++)content.append('(').append(escape(lines.get(n))).append(") Tj T*\n");content.append("ET BT /F1 9 Tf 40 30 Td (Seite ").append(i+1).append(" / ").append(pages).append(") Tj ET\n");byte[] stream=bytes(content.toString());var b=new ByteArrayOutputStream();b.writeBytes(bytes("<< /Length "+stream.length+" >>\nstream\n"));b.writeBytes(stream);b.writeBytes(bytes("endstream"));objects.add(b.toByteArray());}
|
||||
var out=new ByteArrayOutputStream();out.writeBytes(bytes("%PDF-1.4\n%âãÏÓ\n"));var offsets=new ArrayList<Integer>();offsets.add(0);for(int i=0;i<objects.size();i++){offsets.add(out.size());out.writeBytes(bytes((i+1)+" 0 obj\n"));out.writeBytes(objects.get(i));out.writeBytes(bytes("\nendobj\n"));}int start=out.size();out.writeBytes(bytes("xref\n0 "+offsets.size()+"\n0000000000 65535 f \n"));for(int i=1;i<offsets.size();i++)out.writeBytes(bytes(String.format(Locale.ROOT,"%010d 00000 n \n",offsets.get(i))));out.writeBytes(bytes("trailer\n<< /Size "+offsets.size()+" /Root 1 0 R >>\nstartxref\n"+start+"\n%%EOF\n"));return out.toByteArray();
|
||||
}
|
||||
static String escape(String s){return s.replace("\\","\\\\").replace("(","\\(").replace(")","\\)").replaceAll("[\\p{Cntrl}]"," ");}
|
||||
static byte[] bytes(String s){return s.getBytes(WIN);}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
@RestController
|
||||
@RequestMapping("/api/statistics")
|
||||
class StatisticsController {
|
||||
private final StatisticsService service;
|
||||
StatisticsController(StatisticsService service){this.service=service;}
|
||||
@GetMapping("/options") Map<String,Object> options(Authentication auth){return service.options(ApiController.user(auth));}
|
||||
@PostMapping("/query") Map<String,Object> calculate(Authentication auth,@RequestBody StatisticsService.Query query){return service.calculate(ApiController.user(auth),query);}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** Server-side statistics, independent of the paged order overview. No writes or archive guessing. */
|
||||
@Service
|
||||
public class StatisticsService {
|
||||
private final DatabaseService db;
|
||||
private final OrderEntryCatalog catalog;
|
||||
private final String logTable;
|
||||
public StatisticsService(DatabaseService db,OrderEntryCatalog catalog,@org.springframework.beans.factory.annotation.Value("${app.history-schema:phoenix_log}") String schema){
|
||||
this.db=db;this.catalog=catalog;
|
||||
if(!schema.matches("[A-Za-z0-9_]{1,64}"))throw new IllegalArgumentException("Ungültiges Protokollschema.");
|
||||
this.logTable="`"+schema+"`.log";
|
||||
}
|
||||
enum DateBasis { CREATED("jb_createtime"),BOOKED("jb_booktime"),FINISHED("jb_finishtime"),EXPORTED("jb_export_time"),ORDERED("jb_ordertime");final String column;DateBasis(String column){this.column=column;} }
|
||||
enum State { ALL,FINISHED,EXPORTED }
|
||||
enum Amount { ALL,ORDERS,CREDITS }
|
||||
enum Payment { ALL,INVOICE,CASH }
|
||||
enum Period { TOTAL,DAY,WEEK,MONTH,YEAR,HALF_HOUR,HOUR,TWO_HOURS }
|
||||
enum Split { CUSTOMER,COURIER,HEADQUARTERS,COST_CENTER,VEHICLE_TYPE,ACTUAL_VEHICLE_TYPE,VEHICLE,EMPLOYEE,ORDER_TYPE,START_POSTAL,END_POSTAL,PARTNER_COMMISSION }
|
||||
record Interval(LocalDate from,LocalDate to) {}
|
||||
record Query(Interval interval,List<Interval> comparisons,List<Long> headquarters,DateBasis dateBasis,State state,Amount amount,Payment payment,Period period,List<Split> splits,
|
||||
String customerNumber,String courierNumber,Long customerGroup,Long courierGroup,Boolean retrospective,Boolean permanent,Boolean longhaul,LocalTime timeFrom,LocalTime timeTo,boolean excludeFreeCancellations,boolean relatedCustomer) {}
|
||||
record Sql(String text,Object[] values) {}
|
||||
public Map<String,Object> options(LegacyUser user) {
|
||||
user.require(8);
|
||||
var hqs=db.queryForList("SELECT DISTINCT h.hq_id AS id,h.hq_mnemonic AS label FROM headquarters h JOIN mandatorheadquarters m ON m.hq_id=h.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE own.hq_id=? AND own.md_id>0"+(user.hasRight(10)?"":" AND h.hq_id=?")+" ORDER BY h.hq_mnemonic",user.hasRight(10)?new Object[]{user.headquartersId()}:new Object[]{user.headquartersId(),user.headquartersId()});
|
||||
var groups=db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? AND (hq_id=? OR hq_id=0) ORDER BY grp_name",mandator(db,user),user.headquartersId());
|
||||
return fields("headquarters",hqs,"currentHeadquarters",user.headquartersId(),"groups",groups,"excludeFreeCancellations",catalog.enabled(user.headquartersId(),"STATISTIC_NO_STORNOJOBS"),"relatedCustomer",catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER"));
|
||||
}
|
||||
private List<Long> headquarters(LegacyUser user,List<Long> requested) {
|
||||
if(requested==null||requested.isEmpty())return List.of(user.headquartersId());
|
||||
if(requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Ungültige Niederlassungsauswahl.");
|
||||
var ids=requested.stream().distinct().sorted().toList();
|
||||
if(ids.stream().allMatch(id->id==user.headquartersId()))return ids;
|
||||
user.require(10);
|
||||
var allowed=db.queryForList("SELECT DISTINCT target.hq_id FROM mandatorheadquarters target JOIN mandatorheadquarters own ON own.md_id=target.md_id WHERE own.hq_id=? AND own.md_id>0",user.headquartersId()).stream().map(r->id(r,"hq_id")).toList();
|
||||
if(!allowed.containsAll(ids))throw new AccessDeniedException("Eine Niederlassung ist nicht für diesen Mandanten freigegeben.");
|
||||
return ids;
|
||||
}
|
||||
public Map<String,Object> calculate(LegacyUser user,Query query) {
|
||||
user.require(8);validate(query);
|
||||
return db.readTransaction(()->{
|
||||
List<Long> hqs=headquarters(user,query.headquarters());
|
||||
for(Long id:Arrays.asList(query.customerGroup(),query.courierGroup()))if(id!=null && db.queryForList("SELECT grp_id FROM groups WHERE grp_id=? AND md_id=? AND (hq_id=0 OR hq_id IN ("+String.join(",",Collections.nCopies(hqs.size(),"?"))+"))",groupArgs(id,mandator(db,user),hqs)).isEmpty())throw bad("Gruppe ist nicht für die gewählten Niederlassungen freigegeben.");
|
||||
var intervals=new ArrayList<Interval>();intervals.add(query.interval());if(query.comparisons()!=null)intervals.addAll(query.comparisons());
|
||||
var results=new ArrayList<Map<String,Object>>();
|
||||
for(var interval:intervals) {
|
||||
var sql=sql(query,interval,hqs,false,logTable);var rows=db.queryForList(sql.text(),sql.values());
|
||||
if(rows.size()>5000)throw bad("Mehr als 5000 Statistikzeilen. Bitte Gruppierung oder Zeitraum eingrenzen.");
|
||||
var total=sql(query,interval,hqs,true,logTable);
|
||||
results.add(fields("interval",interval,"rows",rows,"totals",one(db.queryForList(total.text(),total.values()))));
|
||||
}
|
||||
return fields("periods",results,"dateBasis",query.dateBasis(),"period",query.period(),"splits",query.splits()==null?List.of():query.splits(),"source","Bestandsaufträge","completePeriod",true);
|
||||
});
|
||||
}
|
||||
private static Object[] groupArgs(long id,long md,List<Long> hqs){var args=new ArrayList<Object>(List.of(id,md));args.addAll(hqs);return args.toArray();}
|
||||
static void validate(Query q) {
|
||||
if(q==null||q.interval()==null||q.dateBasis()==null||q.state()==null||q.amount()==null||q.payment()==null||q.period()==null)throw bad("Statistikoptionen fehlen.");
|
||||
range(q.interval().from(),q.interval().to());
|
||||
if(q.comparisons()!=null){if(q.comparisons().size()>5)throw bad("Höchstens fünf Vergleichszeiträume sind möglich.");for(var i:q.comparisons()){if(i==null)throw bad("Vergleichszeitraum fehlt.");range(i.from(),i.to());}}
|
||||
if(q.splits()!=null && (q.splits().size()>5 || q.splits().stream().anyMatch(Objects::isNull) || q.splits().stream().distinct().count()!=q.splits().size()))throw bad("Bitte höchstens fünf unterschiedliche Aufteilungen wählen.");
|
||||
for(var value:Arrays.asList(q.customerNumber(),q.courierNumber()))if(value!=null && value.length()>50)throw bad("Kunden-/Transporteursnummer ist zu lang.");
|
||||
if((q.timeFrom()==null)!=(q.timeTo()==null))throw bad("Bitte beide Uhrzeiten angeben.");
|
||||
}
|
||||
static Sql sql(Query q,Interval interval,List<Long> headquarters,boolean total,String logTable) {
|
||||
String at="j."+q.dateBasis().column;
|
||||
var args=new ArrayList<Object>(headquarters);
|
||||
String where="j.hq_id IN ("+String.join(",",Collections.nCopies(headquarters.size(),"?"))+") AND "+at+">=? AND "+at+"<?";
|
||||
args.add(interval.from().atStartOfDay());args.add(interval.to().plusDays(1).atStartOfDay());
|
||||
if(q.state()!=State.ALL)where+=" AND j.jb_status=2";
|
||||
if(q.state()==State.EXPORTED)where+=" AND j.jb_export_time IS NOT NULL AND j.jb_export_time>'0000-00-00 00:00:00' AND j.jb_export_time<'9999-12-31 23:59:59'";
|
||||
if(q.amount()==Amount.ORDERS)where+=" AND j.jb_totalprice>0";
|
||||
if(q.amount()==Amount.CREDITS)where+=" AND j.jb_totalprice<0";
|
||||
if(q.excludeFreeCancellations())where+=" AND COALESCE(j.jb_storno,0) IN (0,1,3)";
|
||||
if(q.payment()==Payment.CASH)where+=" AND j.jb_payment=2";
|
||||
if(q.payment()==Payment.INVOICE)where+=" AND j.jb_payment IN (0,1)";
|
||||
if(q.customerNumber()!=null&&!q.customerNumber().isBlank()){where+=" AND cs.cs_eid=?";args.add(q.customerNumber().strip());}
|
||||
if(q.courierNumber()!=null&&!q.courierNumber().isBlank()){where+=" AND cr.cr_eid=?";args.add(q.courierNumber().strip());}
|
||||
if(q.customerGroup()!=null){where+=" AND cs.cs_group LIKE ?";args.add("%,"+q.customerGroup()+",%");}
|
||||
if(q.courierGroup()!=null){where+=" AND cr.cr_group LIKE ?";args.add("%,"+q.courierGroup()+",%");}
|
||||
if(q.retrospective()!=null)where+=" AND "+(q.retrospective()?"":"NOT ")+"EXISTS (SELECT 1 FROM "+logTable+" log WHERE log.jb_id=j.jb_id AND log.logo_id IN (1,25) AND log.logo_description LIKE 'Nacherfassung%')";
|
||||
if(q.permanent()!=null)where+=" AND COALESCE(j.jb_permanent,0)"+(q.permanent()?">0":"=0");
|
||||
if(q.longhaul()!=null)where+=" AND COALESCE(j.jb_longhaul,0)="+(q.longhaul()?1:0);
|
||||
if(q.timeFrom()!=null){where+=" AND (TIME("+at+")>=? "+(q.timeFrom().isAfter(q.timeTo())?"OR":"AND")+" TIME("+at+")<=?)";args.add(q.timeFrom());args.add(q.timeTo().withSecond(59));}
|
||||
String period=switch(q.period()){
|
||||
case TOTAL->"'Gesamt'";case DAY->"DATE_FORMAT("+at+",'%Y-%m-%d')";case WEEK->"DATE_FORMAT("+at+",'%x-W%v')";case MONTH->"DATE_FORMAT("+at+",'%Y-%m')";case YEAR->"YEAR("+at+")";
|
||||
case HALF_HOUR->"CONCAT(DATE_FORMAT("+at+",'%H:'),IF(MINUTE("+at+")<30,'00','30'))";case HOUR->"DATE_FORMAT("+at+",'%H:00')";case TWO_HOURS->"CONCAT(LPAD(FLOOR(HOUR("+at+")/2)*2,2,'0'),':00')";};
|
||||
var columns=new ArrayList<String>();columns.add(total?"'Gesamt' AS period":period+" AS period");
|
||||
var groups=new ArrayList<String>();groups.add("period");
|
||||
if(!total&&q.splits()!=null)for(int i=0;i<q.splits().size();i++) {
|
||||
String expr=switch(q.splits().get(i)){
|
||||
case CUSTOMER->"CONCAT_WS(' · ',cs.cs_id,cs.cs_eid,cmp.cmp_comp)";case COURIER->"CONCAT_WS(' · ',cr.cr_id,cr.cr_eid,CONCAT_WS(' ',cu.usr_firstname,cu.usr_name))";
|
||||
case HEADQUARTERS->"CONCAT_WS(' · ',j.hq_id,h.hq_mnemonic)";case COST_CENTER->"CONCAT_WS(' · ',cc.csc_id,cc.csc_name)";case VEHICLE_TYPE->"COALESCE(CONVERT((SELECT MIN(mt_value) FROM metatype WHERE mt_type='vehicletype' AND mt_sort=j.vht_id) USING utf8mb4) COLLATE utf8mb4_unicode_ci,CAST(j.vht_id AS CHAR))";
|
||||
case ACTUAL_VEHICLE_TYPE->"COALESCE(CONVERT((SELECT MIN(mt_value) FROM metatype WHERE mt_type='vehicletype' AND mt_sort=j.vht_id_real) USING utf8mb4) COLLATE utf8mb4_unicode_ci,CAST(j.vht_id_real AS CHAR))";
|
||||
case VEHICLE->"j.cr_sid";case EMPLOYEE->"CONCAT_WS(' · ',j.emp_id,CONCAT_WS(' ',eu.usr_firstname,eu.usr_name))";case ORDER_TYPE->"CAST(j.jb_type AS CHAR)";
|
||||
case START_POSTAL->"(SELECT a.ad_zipcode FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=j.jb_id ORDER BY t.tr_sort,t.tr_id LIMIT 1)";
|
||||
case END_POSTAL->"(SELECT a.ad_zipcode FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=j.jb_id ORDER BY t.tr_sort DESC,t.tr_id DESC LIMIT 1)";
|
||||
case PARTNER_COMMISSION->"CAST((SELECT MAX(p.jbprc_price) FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort=14) AS CHAR)";
|
||||
};
|
||||
columns.add("COALESCE("+expr+",'–') AS split"+i);groups.add("split"+i);
|
||||
}
|
||||
// Scalar lookups do not multiply jobs when legacy auxiliary rows are duplicated.
|
||||
String courier="IF(j.jb_cr_price IS NOT NULL AND j.jb_cr_price<>0,j.jb_cr_price,ROUND(j.jb_totalprice*(100-(SELECT CASE WHEN COUNT(*)=1 THEN MAX(v.crvh_prov) ELSE NULL END FROM couriervehicle v WHERE v.crvh_sid=j.cr_sid AND v.cr_id=j.cr_id))/100,2))";
|
||||
columns.add("COUNT(*) AS orders");columns.add("COALESCE(SUM(j.jb_totalprice),0) AS revenue");
|
||||
columns.add("SUM(CASE WHEN ("+courier+") IS NULL THEN 1 ELSE 0 END) AS missingCourierValues");
|
||||
columns.add("CASE WHEN COUNT("+courier+")=COUNT(*) THEN COALESCE(SUM("+courier+"),0) ELSE NULL END AS courierRevenue");
|
||||
columns.add("COALESCE(SUM(j.jb_serviceprice),0) AS serviceRevenue");columns.add("COALESCE(SUM(j.jb_totalprice-j.jb_subtotalprice-j.jb_serviceprice),0) AS fuelMarkup");
|
||||
columns.add("COALESCE(SUM((SELECT SUM(p.jbprc_price) FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort=3)),0) AS courierService");
|
||||
for(var measure:Map.of("kilometers",11,"co2eKg",12).entrySet()) {
|
||||
columns.add("SUM((SELECT SUM(p.jbprc_price) FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort="+measure.getValue()+")) AS "+measure.getKey());
|
||||
columns.add("SUM(CASE WHEN EXISTS (SELECT 1 FROM jobprice p WHERE p.jb_id=j.jb_id AND p.mt_sort="+measure.getValue()+") THEN 1 ELSE 0 END) AS "+measure.getKey()+"Orders");
|
||||
}
|
||||
String from=" FROM job j LEFT JOIN costcenter cc ON cc.csc_id=j."+(q.relatedCustomer()?"csc_id_related":"csc_id_payer")+" LEFT JOIN customer cs ON cs.cs_id=cc.cs_id LEFT JOIN company cmp ON cmp.cmp_id=cs.cmp_id LEFT JOIN courier cr ON cr.cr_id=j.cr_id LEFT JOIN user cu ON cu.usr_id=cr.usr_id LEFT JOIN user eu ON eu.usr_id=j.emp_id LEFT JOIN headquarters h ON h.hq_id=j.hq_id ";
|
||||
return new Sql("SELECT "+String.join(",",columns)+from+" WHERE "+where+(total?"":" GROUP BY "+String.join(",",groups)+" ORDER BY "+String.join(",",groups)+" LIMIT 5001"),args.toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.AdminSupport.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.ServicePriceAdministration.amount;
|
||||
|
||||
/** Directed PLZ/area matrices and symmetric, non-transitive neighborhood relations from PHP. */
|
||||
@Service
|
||||
class ZonePriceAdministration {
|
||||
enum Kind {
|
||||
POSTAL("serviceplz","srvp"), AREA("serviceplzarea","srvpa");
|
||||
final String table,p; Kind(String table,String p){this.table=table;this.p=p;}
|
||||
String first(){return p+"_id";} String second(){return p+"2_id";}
|
||||
}
|
||||
record Query(Kind kind,long customer,long vehicle,String rowStart,String columnStart,int rowOffset,int columnOffset) {}
|
||||
record Cell(long from,long to,String value,String courier) {}
|
||||
record Save(String requestId,Query query,String revision,boolean discount,List<Cell> cells) {}
|
||||
enum Action { CREATE, RENAME, LINK, UNLINK, NEIGHBOR_ADD, NEIGHBOR_REMOVE }
|
||||
record Change(String requestId,Kind kind,long id,String revision,Action action,String name,List<Long> targets) {}
|
||||
private final DatabaseService db; private final OrderEntryCatalog catalog; private final ObjectMapper json;
|
||||
ZonePriceAdministration(DatabaseService db,OrderEntryCatalog catalog,ObjectMapper json){this.db=db;this.catalog=catalog;this.json=json;}
|
||||
LocalDateTime now(){return LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0);}
|
||||
void access(LegacyUser u){u.require(2);}
|
||||
String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
|
||||
List<Map<String,Object>> vehicles(){return db.queryForList("SELECT mt_sort AS id,mt_value AS label FROM metatype WHERE mt_type='vehicletype' ORDER BY mt_sort");}
|
||||
Set<String> excluded(LegacyUser u){return new HashSet<>(Arrays.asList(catalog.parameter(u.headquartersId(),"MASK_EXCLUDE_VHT_IDS").replace(" ","").split(",")));}
|
||||
Map<String,Object> options(LegacyUser u){access(u);boolean enabled=catalog.enabled(u.headquartersId(),"SERVICE_VEHICLE_TYPE_ENABLED");return fields("customers",u.hasRight(0),"vehicleEnabled",enabled,"vehicles",enabled?vehicles().stream().filter(v->!excluded(u).contains(text(v,"id"))).toList():List.of());}
|
||||
long scope(LegacyUser u,Query q){access(u);if(q==null||q.kind()==null||q.customer()<0||q.vehicle()<0||q.rowOffset()<0||q.columnOffset()<0||q.rowOffset()>100000||q.columnOffset()>100000)throw bad("Ungültige Tarifauswahl.");
|
||||
start(q.rowStart());start(q.columnStart());if(q.customer()>0){u.require(0);one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id=?",q.customer(),u.headquartersId()));}
|
||||
if(q.vehicle()==0)return 0;
|
||||
if(!catalog.enabled(u.headquartersId(),"SERVICE_VEHICLE_TYPE_ENABLED")||excluded(u).contains(""+q.vehicle()))throw bad("Dieser Fahrzeugtarif ist nicht freigegeben.");
|
||||
var all=vehicles();if(all.stream().noneMatch(v->id(v,"id")==q.vehicle()))throw bad("Unbekannter Fahrzeugtyp.");
|
||||
for(String pair:catalog.parameter(u.headquartersId(),"SERVICE_VEHICLE_TYPE_MAPPING").split("\\|")){String[] parts=pair.split("=",2);if(parts.length==2&&parts[0].strip().equals(""+q.vehicle())&&parts[1].strip().matches("[0-9]{1,9}")){long mapped=Long.parseLong(parts[1].strip());if(all.stream().anyMatch(v->id(v,"id")==mapped))return mapped;}}
|
||||
return q.vehicle();
|
||||
}
|
||||
static String start(String s){s=Objects.requireNonNullElse(s,"").strip();if(s.length()>10)throw bad("Startwert: höchstens zehn Zeichen.");return s;}
|
||||
String label(Kind k){return k==Kind.AREA?"srvpa_name":"CONCAT_WS(' · ',srvp_plz,NULLIF(srvp_country,''))";}
|
||||
List<Map<String,Object>> page(LegacyUser u,Kind k,String start,int offset){String name=k==Kind.AREA?"srvpa_name":"srvp_plz";return k==Kind.AREA?db.queryForList("SELECT srvpa_id AS id,srvpa_name AS name FROM serviceplzarea WHERE hq_id=? AND srvpa_name>=? ORDER BY srvpa_name,srvpa_id LIMIT 11 OFFSET ?",u.headquartersId(),start(start),offset):db.queryForList("SELECT srvp_id AS id,"+label(k)+" AS name FROM serviceplz WHERE srvp_valid=1 AND srvp_plz>=? ORDER BY "+name+",srvp_country,srvp_id LIMIT 11 OFFSET ?",start(start),offset);}
|
||||
List<Map<String,Object>> search(LegacyUser u,Kind k,String term){access(u);if(k==null)throw bad("Art fehlt.");term=AddressAdministration.like(start(term))+"%";return k==Kind.AREA?db.queryForList("SELECT srvpa_id AS id,srvpa_name AS name FROM serviceplzarea WHERE hq_id=? AND srvpa_name LIKE ? ESCAPE '!' ORDER BY srvpa_name,srvpa_id LIMIT 100",u.headquartersId(),term):db.queryForList("SELECT srvp_id AS id,"+label(k)+" AS name FROM serviceplz WHERE srvp_plz LIKE ? ESCAPE '!' ORDER BY srvp_plz,srvp_country,srvp_id LIMIT 100",term);}
|
||||
Map<String,Object> item(LegacyUser u,Kind k,long id){if(k==null)throw bad("Art fehlt.");return one(k==Kind.AREA?db.queryForList("SELECT srvpa_id AS id,srvpa_name AS name FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",id,u.headquartersId()):db.queryForList("SELECT srvp_id AS id,"+label(k)+" AS name FROM serviceplz WHERE srvp_id=?",id));}
|
||||
static String key(long from,long to){return from+":"+to;}
|
||||
static String marks(int count){return String.join(",",Collections.nCopies(count,"?"));}
|
||||
record State(long vehicle,List<Map<String,Object>> rows,List<Map<String,Object>> columns,boolean moreRows,boolean moreColumns,List<Map<String,Object>> current,List<Map<String,Object>> cells,String revision) {}
|
||||
State state(LegacyUser u,Query q,boolean lock){long vehicle=scope(u,q);Kind k=q.kind();var rp=page(u,k,q.rowStart(),q.rowOffset());var cp=page(u,k,q.columnStart(),q.columnOffset());var rows=rp.stream().limit(10).toList();var cols=cp.stream().limit(10).toList();
|
||||
var current=matrix(u,q,vehicle,rows,cols,q.customer(),lock);var general=q.customer()>0?matrix(u,q,vehicle,rows,cols,0,false):List.<Map<String,Object>>of();var cells=new ArrayList<Map<String,Object>>();
|
||||
for(var r:rows)for(var c:cols){long from=id(r,"id"),to=id(c,"id");var matching=current.stream().filter(v->id(v,"fromId")==from&&id(v,"toId")==to).toList();var base=general.stream().filter(v->id(v,"fromId")==from&&id(v,"toId")==to).toList();var cell=new LinkedHashMap<String,Object>(fields("from",from,"to",to,"ambiguous",matching.size()>1,"generalAmbiguous",base.size()>1));var v=ServicePriceAdministration.values(matching.isEmpty()?Map.of():matching.getFirst());var b=ServicePriceAdministration.values(base.isEmpty()?Map.of():base.getFirst());String[] names={"price","discount","courier","courierDiscount"};for(int i=0;i<4;i++){cell.put(names[i],v.get(i));cell.put("general"+Character.toUpperCase(names[i].charAt(0))+names[i].substring(1),b.get(i));}cells.add(cell);}
|
||||
return new State(vehicle,rows,cols,rp.size()>10,cp.size()>10,current,cells,revision(fields("query",q,"hq",u.headquartersId(),"vehicle",vehicle,"rows",rows,"columns",cols,"current",current,"general",general)));
|
||||
}
|
||||
List<Map<String,Object>> matrix(LegacyUser u,Query q,long vehicle,List<Map<String,Object>> rows,List<Map<String,Object>> cols,long customer,boolean lock){if(rows.isEmpty()||cols.isEmpty())return List.of();var k=q.kind();String p=k.p+"c";String sql="SELECT "+k.first()+" AS fromId,"+k.second()+" AS toId,"+p+"_price AS price,"+p+"_discount AS discount,"+p+"_cr_price AS courier,"+p+"_cr_discount AS courierDiscount FROM "+k.table+"customer WHERE hq_id=? AND cs_id=? AND vht_id=? AND "+k.first()+" IN ("+marks(rows.size())+") AND "+k.second()+" IN ("+marks(cols.size())+") ORDER BY "+k.first()+","+k.second();var args=new ArrayList<Object>(List.of(u.headquartersId(),customer,vehicle));rows.forEach(r->args.add(id(r,"id")));cols.forEach(c->args.add(id(c,"id")));return lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());}
|
||||
Map<String,Object> grid(LegacyUser u,Query q){access(u);return db.readTransaction(()->{var s=state(u,q,false);return fields("rows",s.rows(),"columns",s.columns(),"moreRows",s.moreRows(),"moreColumns",s.moreColumns(),"cells",s.cells(),"revision",s.revision(),"vehicle",s.vehicle());});}
|
||||
BigDecimal input(String value,Kind kind,boolean discount,boolean courier){var n=ServicePriceAdministration.input(value,discount);String max=discount||kind==Kind.AREA&&!courier?"999.99":"9999.99";if(n.abs().compareTo(new BigDecimal(max))>0)throw bad("Der Wert überschreitet den Wertebereich der Tarifdatenbank ("+max+").");return n;}
|
||||
boolean receipt(LegacyUser u,String token,String hash){var r=db.lockRows("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='zoneadmin' AND gdc_obj_id=? AND gdc_gen_fieldname=?",u.userId(),"ng_zone_"+token);if(r.isEmpty())return false;if(r.size()!=1||!text(r.getFirst(),"gdc_content").equals(hash))throw bad("Vorgangskennung gehört zu einer anderen Änderung.");return true;}
|
||||
void done(LegacyUser u,String token,String hash){db.insert("genericdatacontainer",fields("gdc_obj_type","zoneadmin","gdc_obj_id",u.userId(),"gdc_gen_fieldname","ng_zone_"+token,"gdc_content",hash,"gdc_context","DONE"));}
|
||||
Map<String,Object> save(LegacyUser u,Save r){access(u);if(r==null||r.cells()==null||r.cells().isEmpty()||r.cells().size()>100)throw bad("Bitte 1 bis 100 geänderte Tarifzellen übergeben.");scope(u,r.query());String token=FilterAdministration.token(r.requestId()),hash=revision(fields("hq",u.headquartersId(),"request",encode(r)));var seen=new HashSet<String>();for(var c:r.cells()){if(c==null||!seen.add(key(c.from(),c.to())))throw bad("Tarifzelle doppelt übergeben.");input(c.value(),r.query().kind(),r.discount(),false);input(c.courier(),r.query().kind(),r.discount(),true);}
|
||||
return db.writeTransaction(()->{lockHeadquarters(db,u);if(receipt(u,token,hash))return fields("saved",true,"repeated",true);var q=r.query();var s=state(u,q,true);if(!s.revision().equals(r.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Tarife wurden zwischenzeitlich geändert. Bitte neu laden.");var k=q.kind();int changed=0;
|
||||
for(var c:r.cells()){var old=s.cells().stream().filter(v->id(v,"from")==c.from()&&id(v,"to")==c.to()).findFirst().orElseThrow(()->bad("Tarifzelle ist nicht freigegeben."));if(Boolean.TRUE.equals(old.get("ambiguous")))throw bad("Mehrere Bestandszeilen für diese Tarifzelle. Bitte zuerst im Bestand klären.");var value=input(c.value(),k,r.discount(),false);var courier=input(c.courier(),k,r.discount(),true);var price=amount(old.get("price"));var discount=amount(old.get("discount"));var cr=amount(old.get("courier"));var crDiscount=amount(old.get("courierDiscount"));if(value.compareTo(r.discount()?discount:price)==0&&courier.compareTo(r.discount()?crDiscount:cr)==0)continue;if(r.discount()){discount=value;crDiscount=courier;}else{price=value;cr=courier;}
|
||||
String where=k.first()+"=? AND "+k.second()+"=? AND hq_id=? AND cs_id=? AND vht_id=?";Object[] keys={c.from(),c.to(),u.headquartersId(),q.customer(),s.vehicle()};String h=k.p+"h",p=k.p+"c";LocalDateTime at=now();var history=db.lockRows("SELECT "+h+"_validfrom AS validFrom FROM "+k.table+"history WHERE "+where+" AND "+h+"_validfrom>=?",c.from(),c.to(),u.headquartersId(),q.customer(),s.vehicle(),at);if(!history.isEmpty())throw bad("Dieser Tarif wurde gerade gespeichert oder hat künftige Historieneinträge. Bitte neu laden bzw. den Bestand klären.");
|
||||
boolean exists=s.current().stream().anyMatch(v->id(v,"fromId")==c.from()&&id(v,"toId")==c.to());if(exists)db.update("UPDATE "+k.table+"customer SET "+p+"_price=?,"+p+"_discount=?,"+p+"_cr_price=?,"+p+"_cr_discount=? WHERE "+where,price,discount,cr,crDiscount,keys[0],keys[1],keys[2],keys[3],keys[4]);else insertRow(k.table+"customer",fields(k.first(),c.from(),k.second(),c.to(),"hq_id",u.headquartersId(),"cs_id",q.customer(),"vht_id",s.vehicle(),p+"_price",price,p+"_discount",discount,p+"_cr_price",cr,p+"_cr_discount",crDiscount));
|
||||
insertRow(k.table+"history",fields(k.first(),c.from(),k.second(),c.to(),"hq_id",u.headquartersId(),"cs_id",q.customer(),"vht_id",s.vehicle(),"usr_id",u.userId(),h+"_validfrom",at,h+"_price",price,h+"_discount",discount,h+"_cr_price",cr,h+"_cr_discount",crDiscount));changed++;
|
||||
}done(u,token,hash);return fields("saved",true,"changed",changed);
|
||||
});
|
||||
}
|
||||
Map<String,Object> details(LegacyUser u,Kind k,long id){access(u);return db.readTransaction(()->detail(u,k,id,false));}
|
||||
Map<String,Object> detail(LegacyUser u,Kind k,long id,boolean lock){if(lock&&k==Kind.AREA){one(db.lockRows("SELECT srvpa_id FROM serviceplzarea WHERE srvpa_id=? AND hq_id=?",id,u.headquartersId()));db.lockRows("SELECT srvpa_id,srvp_id FROM serviceplzareamapping WHERE srvpa_id=?",id);}var selected=item(u,k,id);String where=k==Kind.POSTAL?"n.hq_id=? AND ":"";var args=new ArrayList<Object>();if(k==Kind.POSTAL)args.add(u.headquartersId());args.add(id);args.add(id);
|
||||
String sql="SELECT n."+k.first()+" AS fromId,n."+k.second()+" AS toId,n."+k.p+"n_sort AS sort FROM "+k.table+"neighbour n WHERE "+where+"(n."+k.first()+"=? OR n."+k.second()+"=?) ORDER BY n."+k.first()+",n."+k.second()+",n."+k.p+"n_sort";var links=lock?db.lockRows(sql,args.toArray()):db.queryForList(sql,args.toArray());var neighbors=new ArrayList<Map<String,Object>>();var ids=new TreeSet<Long>();for(var link:links)ids.add(id(link,"fromId")==id?id(link,"toId"):id(link,"fromId"));for(long other:ids){var it=item(u,k,other);neighbors.add(fields("id",other,"name",it.get("name"),"symmetric",links.stream().anyMatch(v->id(v,"fromId")==id&&id(v,"toId")==other)&&links.stream().anyMatch(v->id(v,"fromId")==other&&id(v,"toId")==id)));}
|
||||
var mappings=k==Kind.AREA?db.queryForList("SELECT p.srvp_id AS id,"+label(Kind.POSTAL)+" AS name FROM serviceplzareamapping m JOIN serviceplz p ON p.srvp_id=m.srvp_id WHERE m.srvpa_id=? ORDER BY p.srvp_plz,p.srvp_country,p.srvp_id",id):List.<Map<String,Object>>of();return fields("item",selected,"neighbors",neighbors,"postals",mappings,"revision",revision(fields("item",selected,"links",links,"mappings",mappings)));
|
||||
}
|
||||
Map<String,Object> change(LegacyUser u,Change c){access(u);if(c==null||c.kind()==null||c.action()==null)throw bad("Änderung fehlt.");if((c.action()==Action.CREATE||c.action()==Action.RENAME||c.action()==Action.LINK||c.action()==Action.UNLINK)&&c.kind()!=Kind.AREA)throw bad("Diese Änderung gilt nur für Bereiche.");String token=FilterAdministration.token(c.requestId()),hash=revision(fields("hq",u.headquartersId(),"request",encode(c)));
|
||||
return db.writeTransaction(()->{lockHeadquarters(db,u);if(receipt(u,token,hash))return fields("saved",true,"repeated",true);if(c.action()!=Action.CREATE){var d=detail(u,c.kind(),c.id(),true);if(!Objects.equals(d.get("revision"),c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Bereich oder Verknüpfungen wurden geändert. Bitte neu laden.");}
|
||||
if(c.action()==Action.CREATE||c.action()==Action.RENAME){String name=required(c.name(),10,"Bereichsname");var duplicates=db.queryForList("SELECT srvpa_id FROM serviceplzarea WHERE hq_id=? AND srvpa_name=? AND srvpa_id<>?",u.headquartersId(),name,c.action()==Action.CREATE?0:c.id());if(!duplicates.isEmpty())throw bad("Dieser Bereichsname existiert bereits.");if(c.action()==Action.CREATE)db.insert("serviceplzarea",fields("hq_id",u.headquartersId(),"srvpa_name",name));else db.update("UPDATE serviceplzarea SET srvpa_name=? WHERE srvpa_id=? AND hq_id=?",name,c.id(),u.headquartersId());
|
||||
}else{if(c.targets()==null||c.targets().isEmpty()||c.targets().size()>100||new HashSet<>(c.targets()).size()!=c.targets().size()||c.targets().stream().anyMatch(v->v==null||v<=0))throw bad("Bitte 1 bis 100 eindeutige Verknüpfungen auswählen.");for(long target:c.targets()){if(c.action()==Action.LINK||c.action()==Action.UNLINK){item(u,Kind.POSTAL,target);if(c.action()==Action.LINK){var assigned=db.lockRows("SELECT m.srvpa_id FROM serviceplzareamapping m JOIN serviceplzarea a ON a.srvpa_id=m.srvpa_id WHERE a.hq_id=? AND m.srvp_id=?",u.headquartersId(),target);if(assigned.stream().anyMatch(v->id(v,"srvpa_id")!=c.id()))throw bad("Diese PLZ gehört bereits zu einem anderen Bereich dieser Niederlassung. Bitte dort zuerst die Verknüpfung entfernen.");if(assigned.isEmpty())insertRow("serviceplzareamapping",fields("srvpa_id",c.id(),"srvp_id",target));}else db.update("DELETE FROM serviceplzareamapping WHERE srvpa_id=? AND srvp_id=?",c.id(),target);
|
||||
}else{item(u,c.kind(),target);if(target==c.id())throw bad("Ein Bereich bzw. eine PLZ kann nicht der eigene Nachbar sein.");neighbor(u,c.kind(),c.id(),target,c.action()==Action.NEIGHBOR_ADD);neighbor(u,c.kind(),target,c.id(),c.action()==Action.NEIGHBOR_ADD);}}
|
||||
}done(u,token,hash);return fields("saved",true);
|
||||
});
|
||||
}
|
||||
// These legacy relation/history tables have no generated primary key.
|
||||
void insertRow(String table,Map<String,Object> values){db.update("INSERT INTO "+table+" ("+String.join(",",values.keySet())+") VALUES ("+marks(values.size())+")",values.values().toArray());}
|
||||
void neighbor(LegacyUser u,Kind k,long from,long to,boolean add){String where=k.first()+"=? AND "+k.second()+"=?"+(k==Kind.POSTAL?" AND hq_id=?":"");Object[] args=k==Kind.POSTAL?new Object[]{from,to,u.headquartersId()}:new Object[]{from,to};if(add){if(db.queryForList("SELECT "+k.first()+" FROM "+k.table+"neighbour WHERE "+where,args).isEmpty()){var values=fields(k.first(),from,k.second(),to,k.p+"n_sort",0);if(k==Kind.POSTAL)values.put("hq_id",u.headquartersId());insertRow(k.table+"neighbour",values);}}else db.update("DELETE FROM "+k.table+"neighbour WHERE "+where,args);}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static de.stadtbote.votian.ApiController.user;
|
||||
@RestController @RequestMapping("/api/administration/zone-prices")
|
||||
class ZonePriceController {
|
||||
private final ZonePriceAdministration service;
|
||||
ZonePriceController(ZonePriceAdministration service){this.service=service;}
|
||||
@GetMapping Map<String,Object> options(Authentication a){return service.options(user(a));}
|
||||
@PostMapping("/query") Map<String,Object> query(Authentication a,@RequestBody ZonePriceAdministration.Query q){return service.grid(user(a),q);}
|
||||
@PutMapping Map<String,Object> save(Authentication a,@RequestBody ZonePriceAdministration.Save s){return service.save(user(a),s);}
|
||||
@GetMapping("/items") List<Map<String,Object>> items(Authentication a,@RequestParam ZonePriceAdministration.Kind kind,@RequestParam(defaultValue="") String q){return service.search(user(a),kind,q);}
|
||||
@GetMapping("/details") Map<String,Object> details(Authentication a,@RequestParam ZonePriceAdministration.Kind kind,@RequestParam long id){return service.details(user(a),kind,id);}
|
||||
@PutMapping("/management") Map<String,Object> change(Authentication a,@RequestBody ZonePriceAdministration.Change c){return service.change(user(a),c);}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionException;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import java.sql.Statement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -24,6 +27,8 @@ public class DatabaseService {
|
||||
private static final Pattern SIDE_EFFECT = Pattern.compile("\\b(?:INTO|FOR\\s+UPDATE|LOCK\\s+IN\\s+SHARE\\s+MODE)\\b", Pattern.CASE_INSENSITIVE);
|
||||
private final JdbcTemplate jdbc;
|
||||
private final TransactionTemplate readTransaction;
|
||||
private final TransactionTemplate writeTransaction;
|
||||
private final ThreadLocal<Boolean> writing = ThreadLocal.withInitial(() -> false);
|
||||
|
||||
@Autowired
|
||||
public DatabaseService(JdbcTemplate jdbc, DataSourceTransactionManager transactions) {
|
||||
@@ -37,6 +42,7 @@ public class DatabaseService {
|
||||
this.jdbc = jdbc;
|
||||
this.readTransaction = new TransactionTemplate(transactions);
|
||||
this.readTransaction.setReadOnly(true);
|
||||
this.writeTransaction = new TransactionTemplate(transactions);
|
||||
}
|
||||
|
||||
/** SQL is application-owned; values must be supplied as bound parameters. No raw JDBC is exposed. */
|
||||
@@ -50,8 +56,40 @@ public class DatabaseService {
|
||||
return readTransaction(() -> jdbc.queryForObject(sql, type, parameters));
|
||||
}
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${app.meta-database-url:}")
|
||||
private String metaUrl = "";
|
||||
@org.springframework.beans.factory.annotation.Value("${app.meta-database-user:${spring.datasource.username:}}")
|
||||
private String metaUser = "";
|
||||
@org.springframework.beans.factory.annotation.Value("${app.meta-database-password:${spring.datasource.password:}}")
|
||||
private String metaPassword = "";
|
||||
|
||||
/** Read-only access to PHP's existing central metaobject database (personal newsletter hashes). */
|
||||
public List<Map<String,Object>> queryMetaForList(String sql, Object... parameters) {
|
||||
requireReadStatement(sql);
|
||||
String url = metaUrl;
|
||||
if (url == null || url.isBlank()) {
|
||||
var configured = queryForList("SELECT par_value FROM parameter WHERE hq_id=0 AND emp_id=0 AND par_key='EXTERNAL_DB_METAOBJECT'");
|
||||
if (configured.size() > 1) throw new DataAccessResourceFailureException("Mehrdeutige Metaobjekt-Datenbank.");
|
||||
String host = configured.isEmpty() ? "172.16.0.111:3711" : Objects.toString(configured.getFirst().get("par_value"), "");
|
||||
if (host.isBlank()) host = "172.16.0.111:3711";
|
||||
if (!host.matches("[A-Za-z0-9.-]+:[0-9]{1,5}")) throw new DataAccessResourceFailureException("Ungültige Metaobjekt-Datenbankadresse.");
|
||||
url = "jdbc:mariadb://" + host + "/meta_object?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false";
|
||||
}
|
||||
if (!url.startsWith("jdbc:mariadb://")) throw new DataAccessResourceFailureException("Ungültige Metaobjekt-Datenbankkonfiguration.");
|
||||
var source = new org.springframework.jdbc.datasource.DriverManagerDataSource(url, metaUser, metaPassword);
|
||||
var manager = new DataSourceTransactionManager(source);
|
||||
manager.setEnforceReadOnly(true);
|
||||
var transaction = new TransactionTemplate(manager);
|
||||
transaction.setReadOnly(true);
|
||||
try { return transaction.execute(status -> new JdbcTemplate(source).queryForList(sql, parameters)); }
|
||||
catch (DataAccessException | TransactionException e) {
|
||||
throw new DataAccessResourceFailureException("Die Metaobjekt-Datenbank für persönliche Newsletter-Abmeldelinks ist nicht erreichbar oder verweigert den Zugriff. META_DB_URL und Zugangsdaten prüfen.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Groups dependent reads (count, orders and stops) in one database transaction. */
|
||||
public <T> T readTransaction(Supplier<T> work) {
|
||||
if (writing.get()) return work.get();
|
||||
try {
|
||||
return readTransaction.execute(status -> work.get());
|
||||
} catch (TransactionException exception) {
|
||||
@@ -59,9 +97,110 @@ public class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Central entry point for future writes. No incomplete legacy workflow may write today. */
|
||||
/** Writes are available only inside an explicit, atomic business transaction. */
|
||||
public <T> T writeTransaction(Supplier<T> work) {
|
||||
if (writing.get() || TransactionSynchronizationManager.isCurrentTransactionReadOnly())
|
||||
throw new IllegalStateException("Ein Schreibvorgang benötigt eine eigene Transaktionsgrenze.");
|
||||
try {
|
||||
return writeTransaction.execute(status -> {
|
||||
writing.set(true);
|
||||
try { return work.get(); }
|
||||
finally { writing.remove(); }
|
||||
});
|
||||
} catch (TransactionException exception) {
|
||||
throw new DataAccessResourceFailureException(DATABASE_UNAVAILABLE, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy contact IDs use MAX+1 and have no unique constraint. A table lock also
|
||||
* serializes PHP writers. Explicit commit/rollback must precede UNLOCK TABLES
|
||||
* (which itself commits in MariaDB). No other business work may run here. */
|
||||
public <T> T contactWriteTransaction(Supplier<T> work) {
|
||||
return metafieldWriteTransaction(work, false);
|
||||
}
|
||||
|
||||
/** Keep the MyISAM report and its access scope stable while writing its EAV form. */
|
||||
public <T> T reportFormWriteTransaction(Supplier<T> work) {
|
||||
return metafieldWriteTransaction(work, true);
|
||||
}
|
||||
|
||||
private <T> T metafieldWriteTransaction(Supplier<T> work, boolean report) {
|
||||
Integer transactional=queryForObject("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('metafieldvalue','metafieldvaluehistory','metafieldcategorykey','metafieldkey') AND ENGINE='InnoDB'",Integer.class);
|
||||
if(!Objects.equals(transactional,4))throw new DataAccessResourceFailureException("Kontaktänderungen benötigen die vorhandenen vier Metafeldtabellen mit InnoDB.");
|
||||
return writeTransaction(() -> {
|
||||
jdbc.execute("LOCK TABLES metafieldvalue WRITE, metafieldvaluehistory WRITE, metafieldcategorykey AS ck READ, metafieldkey AS k READ"+(report?", `phoenix_group`.report_process AS r READ, headquarters AS h READ, mandatorheadquarters AS m READ, mandatorheadquarters AS own READ":""));
|
||||
try {
|
||||
T result=work.get();
|
||||
jdbc.execute("COMMIT");
|
||||
return result;
|
||||
} catch (RuntimeException | Error e) {
|
||||
jdbc.execute("ROLLBACK");
|
||||
throw e;
|
||||
} finally { jdbc.execute("UNLOCK TABLES"); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Group deletion spans MyISAM and InnoDB memberships. A short exclusive lock
|
||||
* prevents the legacy PHP UI from attaching a new member during final cleanup.
|
||||
* Call only after the durable deletion plan has been committed. */
|
||||
public <T> T groupDeleteTransaction(Supplier<T> work) {
|
||||
return writeTransaction(() -> {
|
||||
jdbc.execute("LOCK TABLES groups WRITE, groups AS member_grp WRITE, customer WRITE, customer AS member_cs WRITE, courier WRITE, courier AS member_cr WRITE, courier AS cr READ, couriervehicle WRITE, couriervehicle AS member_crvh WRITE, employee WRITE, employee AS member_emp WRITE, article WRITE, article AS member_at WRITE, service WRITE, service AS member_srv WRITE, servicetype WRITE, servicetype AS member_srvt WRITE, branch WRITE, branch AS member_br WRITE, metafieldcategory WRITE, metafieldcategory AS member_mtfc WRITE, metafieldtemplate WRITE, metafieldtemplate AS member_mtft WRITE, messagegroup WRITE, genericdatacontainer WRITE, mandator WRITE, mandatorheadquarters READ, mandatorheadquarters AS m READ, headquarters AS h READ, user AS u READ");
|
||||
try {T result=work.get();jdbc.execute("COMMIT");return result;}
|
||||
catch(RuntimeException | Error e){jdbc.execute("ROLLBACK");throw e;}
|
||||
finally {jdbc.execute("UNLOCK TABLES");}
|
||||
});
|
||||
}
|
||||
|
||||
/** Customer service assignments include legacy MyISAM tables. The durable
|
||||
* operation is created before this lock; retries validate the saved prefix. */
|
||||
public <T> T customerServiceWriteTransaction(Supplier<T> work) {
|
||||
return writeTransaction(() -> {
|
||||
jdbc.execute("LOCK TABLES customer READ, service READ, servicetype READ, parameter READ, customerservice WRITE, customerservicetype WRITE, genericdatacontainer WRITE");
|
||||
try {T result=work.get();jdbc.execute("COMMIT");return result;}
|
||||
catch(RuntimeException | Error e){jdbc.execute("ROLLBACK");throw e;}
|
||||
finally {jdbc.execute("UNLOCK TABLES");}
|
||||
});
|
||||
}
|
||||
|
||||
public int update(String sql, Object... parameters) {
|
||||
throw new DatabaseWritesDisabledException();
|
||||
requireWriting();
|
||||
String statement = sql == null ? "" : sql.strip();
|
||||
if (!statement.matches("(?is)^(INSERT\\s+INTO|UPDATE|DELETE\\s+FROM)\\b.*")
|
||||
|| statement.contains(";") || statement.contains("--") || statement.contains("/*"))
|
||||
throw new IllegalArgumentException("Nur einzelne DML-Anweisungen sind zulässig.");
|
||||
return jdbc.update(sql, parameters);
|
||||
}
|
||||
|
||||
/** Identifiers are application constants, never request input. Values are always bound. */
|
||||
public long insert(String table, Map<String,Object> values) {
|
||||
requireWriting();
|
||||
if (!table.matches("(?:`[A-Za-z0-9_]{1,64}`\\.)?[a-z][a-z0-9_]*") || values.isEmpty()
|
||||
|| values.keySet().stream().anyMatch(key -> !key.matches("[a-z][a-z0-9_]*")))
|
||||
throw new IllegalArgumentException("Ungültiger Tabellen- oder Spaltenname.");
|
||||
String sql = "INSERT INTO " + table + " (" + String.join(",", values.keySet()) + ") VALUES ("
|
||||
+ String.join(",", java.util.Collections.nCopies(values.size(), "?")) + ")";
|
||||
var keys = new GeneratedKeyHolder();
|
||||
jdbc.update(connection -> {
|
||||
var statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
|
||||
int index = 1;
|
||||
for (Object value : values.values()) statement.setObject(index++, value);
|
||||
return statement;
|
||||
}, keys);
|
||||
Number key = keys.getKey();
|
||||
if (key == null || key.longValue() <= 0)
|
||||
throw new DataAccessResourceFailureException("Der Datensatz konnte nicht eindeutig angelegt werden.");
|
||||
return key.longValue();
|
||||
}
|
||||
|
||||
public List<Map<String,Object>> lockRows(String sql, Object... parameters) {
|
||||
requireWriting();
|
||||
requireReadStatement(sql);
|
||||
return jdbc.queryForList(sql + " FOR UPDATE", parameters);
|
||||
}
|
||||
|
||||
private void requireWriting() {
|
||||
if (!writing.get()) throw new DatabaseWritesDisabledException();
|
||||
}
|
||||
|
||||
public record ConnectionCheck(boolean connected, String reason, String message) {}
|
||||
@@ -70,7 +209,7 @@ public class DatabaseService {
|
||||
try {
|
||||
boolean connected = Objects.equals(queryForObject("SELECT 1", Integer.class), 1);
|
||||
return new ConnectionCheck(connected, connected ? "CONNECTED" : "UNAVAILABLE", connected
|
||||
? "Mit der vorhandenen VoTiAn-Datenbank verbunden. Diese Ausbaustufe liest Bestandsdaten; Erfassung und Änderungen erfolgen im bestehenden VoTiAn."
|
||||
? "Mit der vorhandenen VoTiAn-Datenbank verbunden."
|
||||
: DATABASE_UNAVAILABLE);
|
||||
} catch (DataAccessException exception) {
|
||||
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package de.stadtbote.votian.database;
|
||||
|
||||
/** Business write workflows have not yet been ported; the central database boundary enforces this. */
|
||||
/** A write was attempted outside an explicit business transaction. */
|
||||
public final class DatabaseWritesDisabledException extends UnsupportedOperationException {
|
||||
public DatabaseWritesDisabledException() {
|
||||
super("Schreibzugriffe sind in dieser Ausbaustufe gesperrt. Bitte die bestehende VoTiAn-Anwendung verwenden.");
|
||||
super("Schreibzugriffe sind nur innerhalb eines freigegebenen Fachvorgangs zulässig.");
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ spring:
|
||||
validation-timeout: 1000
|
||||
maximum-pool-size: 4
|
||||
minimum-idle: 0
|
||||
read-only: true
|
||||
# Reads are still enforced as READ ONLY by DatabaseService. Order entry uses explicit write transactions.
|
||||
read-only: false
|
||||
data-source-properties:
|
||||
# Preserve numeric legacy codes, including values above 1 in TINYINT(1).
|
||||
tinyInt1isBit: false
|
||||
@@ -35,12 +36,35 @@ spring:
|
||||
init:
|
||||
mode: never
|
||||
jackson:
|
||||
deserialization:
|
||||
fail-on-unknown-properties: true
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
app:
|
||||
meta-database-url: ${META_DB_URL:}
|
||||
meta-database-user: ${META_DB_USER:${DB_USER:}}
|
||||
meta-database-password: ${META_DB_PASSWORD:${DB_PASSWORD:}}
|
||||
form-images-directory: ${FORM_IMAGES_DIRECTORY:data/form-images}
|
||||
groupware-schema: ${GROUPWARE_DB_SCHEMA:phoenix_group}
|
||||
mail:
|
||||
host: ${SMTP_HOST:}
|
||||
port: ${SMTP_PORT:25}
|
||||
username: ${SMTP_USERNAME:}
|
||||
password: ${SMTP_PASSWORD:}
|
||||
starttls: ${SMTP_STARTTLS:true}
|
||||
ssl: ${SMTP_SSL:false}
|
||||
from: ${SMTP_FROM:}
|
||||
sustainability:
|
||||
url: ${CO2_URL:}
|
||||
username: ${CO2_USERNAME:}
|
||||
password: ${CO2_PASSWORD:}
|
||||
cors-origins: ${CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173}
|
||||
legacy-url: ${LEGACY_APP_URL:}
|
||||
max-results: ${MAX_RESULTS:500}
|
||||
routing:
|
||||
pzm-url: ${PZM_URL:}
|
||||
fallback-url: ${PZM_FALLBACK_URL:}
|
||||
google-api-key: ${GOOGLE_MAPS_API_KEY:}
|
||||
history-schema: ${HISTORY_DB_SCHEMA:phoenix_log}
|
||||
logging:
|
||||
level:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static de.stadtbote.votian.AddressAdministration.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
class AddressAdministrationTest {
|
||||
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final AddressAdministration service=new AddressAdministration(db,new ObjectMapper(),catalog);
|
||||
@Test void rightsApplyToEveryEndpoint(){var u=AppointmentServiceTest.user(0);assertThatThrownBy(()->service.options(u,Kind.POSTAL)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.query(u,Kind.AREA,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.create(u,Kind.POSTAL,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.remove(u,Kind.POSTAL,null)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->service.resume(u,Kind.POSTAL,"bad")).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db);}
|
||||
@Test void validatesRangesAndEscapesSearchWildcards(){assertThat(house("")).isNull();assertThat(house("0012")).isEqualTo(12);assertThatThrownBy(()->house("12a")).hasMessageContaining("Hausnummer");assertThat(like("A_%!B")).isEqualTo("A!_!%!!B%");var s=new Save(UUID.randomUUID().toString(),"10000","Straße","10000","Ort","DE","20","10",0);assertThatThrownBy(()->service.create(AppointmentServiceTest.user(2),Kind.POSTAL,s)).hasMessageContaining("größer");verifyNoInteractions(db);}
|
||||
@Test void rangeAndParityChooseSpecificPostalMapping(){var station=OrderEntryFixtures.request().stops().getFirst();when(db.queryForList(contains("FROM serviceplzaddress m"),any(Object[].class))).thenReturn(List.of(fields("id",1,"targetId",101,"houseFrom",null,"houseTo",null,"mode",0),fields("id",2,"targetId",102,"houseFrom",0,"houseTo","10","mode",2),fields("id",3,"targetId",103,"houseFrom",1,"houseTo","3","mode",1)));assertThat(LegacyAddressTariffs.postal(db,7,station)).isEqualTo(103);verify(db).queryForList(contains("ESCAPE '!'"),eq(7L),eq("20095"),eq("DE"),eq("Teststraße%"));}
|
||||
@Test void areaHonorsExactHouseSuffixOverride(){var original=OrderEntryFixtures.request().stops().getFirst();var tree=new ObjectMapper().valueToTree(original);((com.fasterxml.jackson.databind.node.ObjectNode)tree).put("houseNumber","1 a");var station=new ObjectMapper().convertValue(tree,OrderEntryModels.Station.class);when(db.queryForList(contains("FROM serviceplzareaaddress m"),any(Object[].class))).thenReturn(List.of(fields("id",5,"targetId",201,"houseFrom",null,"houseTo","9","mode",1)));when(catalog.parameter(0,"MASK_AREA_ID_FROM_ADDRESS_5")).thenReturn("1a,202;1b,203");when(db.queryForList(startsWith("SELECT srvpa_id FROM serviceplzarea"),eq(202L),eq(7L))).thenReturn(List.of(Map.of("srvpa_id",202)));assertThat(LegacyAddressTariffs.area(db,catalog,7,101,station)).isEqualTo(202);}
|
||||
@Test void zeroUpperBoundIsNotAnUnlimitedRange(){assertThat(LegacyAddressTariffs.bound(Map.of("to","0"),"to",99999)).isZero();assertThat(LegacyAddressTariffs.bound(Map.of("to",""),"to",99999)).isEqualTo(99999);}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static de.stadtbote.votian.AddressAdministration.*;
|
||||
public class AddressIntegrationAssertions {
|
||||
public static void verify(DatabaseService db){
|
||||
var json=new ObjectMapper();var user=AppointmentServiceTest.user(2);var service=new AddressAdministration(db,json,new OrderEntryCatalog(db));
|
||||
var query=new Query("","","","","","","",null,"street",0);
|
||||
var save=new Save(UUID.randomUUID().toString(),"10000","Musterstraße","20095","Hamburg","DE","1","20",1);
|
||||
assertThat(service.create(user,Kind.POSTAL,save)).containsEntry("state","DONE");assertThat(service.create(user,Kind.POSTAL,save)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzaddress",Integer.class)).isEqualTo(1);
|
||||
assertThat(service.query(user,Kind.POSTAL,query)).containsEntry("total",1L);
|
||||
var row=db.queryForList(service.select(Kind.POSTAL),7).getFirst();assertThat(row).containsEntry("target","10000").containsEntry("street","Musterstraße");
|
||||
assertThatThrownBy(()->service.remove(user,Kind.POSTAL,new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(row,"id"),"stale"))))).hasMessageContaining("409");
|
||||
var wrong=new LegacyUser(user.userId(),user.employeeId(),8,user.username(),user.rights());assertThat(service.query(wrong,Kind.POSTAL,query)).containsEntry("total",0L);assertThatThrownBy(()->service.remove(wrong,Kind.POSTAL,new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(row,"id"),AdminSupport.revision(row)))))).hasMessageContaining("404");
|
||||
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("commit status unavailable")).when(failing).update(startsWith("UPDATE genericdatacontainer SET gdc_context='DONE'"),any(Object[].class));var interrupted=new AddressAdministration(failing,json,new OrderEntryCatalog(failing));
|
||||
var area=new Save(UUID.randomUUID().toString(),"42","Neue Straße","20095","Hamburg","DE","","",2);
|
||||
assertThat(interrupted.create(user,Kind.AREA,area)).containsEntry("state","PENDING");assertThat(db.queryForObject("SELECT COUNT(*) FROM address WHERE ad_street='Neue Straße'",Integer.class)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isEqualTo(1);
|
||||
assertThat(service.resume(user,Kind.AREA,area.requestId())).containsEntry("state","DONE");assertThat(service.create(user,Kind.AREA,area)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isEqualTo(1);
|
||||
var first=db.queryForList(service.select(Kind.AREA),7).getFirst();assertThat(first).containsEntry("target","0042");assertThat(service.exceptions(user,LegacyMapping.id(first,"id"))).containsExactly(Map.of("house","1a","area","0043"));
|
||||
var remove=new Delete(UUID.randomUUID().toString(),List.of(new Selection(LegacyMapping.id(first,"id"),AdminSupport.revision(first))));assertThat(interrupted.remove(user,Kind.AREA,remove)).containsEntry("state","PENDING");assertThat(service.resume(user,Kind.AREA,remove.requestId())).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM serviceplzareaaddress",Integer.class)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM address WHERE ad_street='Neue Straße'",Integer.class)).isEqualTo(1);
|
||||
assertThat(service.query(user,Kind.POSTAL,new Query("NOPE","","","","","","",null,"street",0))).containsEntry("total",0L);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,87 @@ class ApiSecurityTest {
|
||||
return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false);
|
||||
}
|
||||
|
||||
@Test void crmAndMailMutationsRequireSessionAndCsrf() throws Exception {
|
||||
mvc.perform(get("/api/crm/options")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
for(String path:List.of("/api/crm/contacts","/api/crm/targets","/api/appointments/44/notifications/preview","/api/appointments/notifications/test/send"))
|
||||
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());verify(database,never()).contactWriteTransaction(any());
|
||||
}
|
||||
|
||||
@Test void messagesAndFormMutationsRequireSessionAndCsrf() throws Exception {
|
||||
for(String path:List.of("/api/messages/options","/api/forms","/api/newsletters/options","/api/newsletters/mail/options","/api/evaluations/options"))
|
||||
mvc.perform(get(path)).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
for(String path:List.of("/api/messages/bulletins","/api/messages/preview","/api/messages/send/token","/api/messages/groups","/api/forms/categories","/api/forms/fields","/api/forms/templates","/api/newsletters","/api/newsletters/images","/api/newsletters/12/mail/preview","/api/newsletters/mail/token/send","/api/evaluations","/api/evaluations/12/mail/preview","/api/evaluations/mail/token/send"))
|
||||
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
mvc.perform(put("/api/forms/categories/10/fields").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
var token=csrf(session);
|
||||
mvc.perform(get("/api/forms").session(session)).andExpect(status().isForbidden());
|
||||
mvc.perform(get("/api/newsletters/options").session(session)).andExpect(status().isForbidden());
|
||||
mvc.perform(post("/api/messages/bulletins").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void zoneTariffsAndManagementRequireSessionCsrfAndAdministrationRight() throws Exception {
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password"))).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","1")));
|
||||
mvc.perform(get("/api/administration/zone-prices")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
for(String path:List.of("/api/administration/zone-prices","/api/administration/zone-prices/management"))
|
||||
mvc.perform(put(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
var token=csrf(session);
|
||||
mvc.perform(put("/api/administration/zone-prices/management").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void tariffMutationsRequireSessionCsrfAndGeneralPriceRight() throws Exception {
|
||||
mvc.perform(get("/api/administration/service-prices")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
mvc.perform(put("/api/administration/service-prices").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
mvc.perform(put("/api/administration/service-prices/definitions").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
var token=csrf(session);
|
||||
mvc.perform(post("/api/administration/service-prices/query").session(session).header(token.header(),token.token()).contentType("application/json").content("{\"mode\":0,\"customer\":0,\"date\":\"2026-10-13\"}")).andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void filterAdministrationRequiresSessionCsrfAndManageRight() throws Exception {
|
||||
mvc.perform(get("/api/administration/filters")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
mvc.perform(get("/api/administration/filters").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.manage").value(false));
|
||||
mvc.perform(post("/api/administration/filters").session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
var token=csrf(session);
|
||||
mvc.perform(post("/api/administration/filters").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
mvc.perform(delete("/api/administration/filters/1").session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
mvc.perform(post("/api/administration/filters/operations/00000000-0000-0000-0000-000000000000/resume").session(session).header(token.header(),token.token())).andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void addressAdministrationRequiresSessionCsrfAndHeadquartersRight() throws Exception {
|
||||
mvc.perform(get("/api/administration/addresses/POSTAL")).andExpect(status().isUnauthorized());
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password")))
|
||||
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","1")));
|
||||
var session=authenticated("existing-user","existing-password");var token=csrf(session);
|
||||
mvc.perform(get("/api/administration/addresses/POSTAL").session(session)).andExpect(status().isForbidden());
|
||||
for(String path:List.of("/api/administration/addresses/POSTAL","/api/administration/addresses/AREA/remove","/api/administration/addresses/AREA/query","/api/administration/addresses/AREA/operations/00000000-0000-0000-0000-000000000000/resume")) {
|
||||
mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
mvc.perform(post(path).session(session).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
}
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void businessWorkflowsRequireSessionCsrfAndDomainRights() throws Exception {
|
||||
for(String path:List.of("/api/master-data/customers/1","/api/orders/1/workbench","/api/orders/1/cancellation","/api/orders/1/articles","/api/orders/1/calculation","/api/billing/options"))mvc.perform(get(path)).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
for(String path:List.of("/api/orders/1/workbench","/api/orders/1/price","/api/master-data/customers/1"))mvc.perform(put(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
for(String path:List.of("/api/orders/1/actions","/api/orders/1/cancellation","/api/orders/1/cancellation/preview","/api/orders/1/articles/movement","/api/orders/1/articles/movement/preview","/api/billing/assign","/api/billing/operations/test/resume"))mvc.perform(post(path).session(session).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("none"),eq("password"),eq("password"))).thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","none","emp_rights","0000000000")));
|
||||
var restricted=authenticated("none","password");var token=csrf(restricted);
|
||||
mvc.perform(put("/api/master-data/customers/1").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
mvc.perform(post("/api/orders/1/actions").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
mvc.perform(post("/api/billing/assign").session(restricted).header(token.header(),token.token()).contentType("application/json").content("{}")).andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void healthAndSystemArePublicButDataRequiresSession() throws Exception {
|
||||
mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password")))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"));
|
||||
@@ -80,6 +161,20 @@ class ApiSecurityTest {
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test void reportsRequireSessionCsrfAndTheirSeparateLegacyRight() throws Exception {
|
||||
mvc.perform(get("/api/administration/reports/CUSTOMER/options")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
mvc.perform(get("/api/administration").session(session)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.customerReports").value(false)).andExpect(jsonPath("$.courierReports").value(false));
|
||||
mvc.perform(get("/api/administration/reports/CUSTOMER/options").session(session)).andExpect(status().isForbidden());
|
||||
mvc.perform(post("/api/administration/reports/CUSTOMER").session(session).contentType("application/json").content("{}"))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
var token=csrf(session);
|
||||
mvc.perform(post("/api/administration/reports/CUSTOMER").session(session).header(token.header(),token.token()).contentType("application/json").content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
verify(database,never()).insert(anyString(),anyMap());
|
||||
}
|
||||
|
||||
@Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception {
|
||||
Csrf anonymous = csrf(null);
|
||||
String previousId = anonymous.session().getId();
|
||||
@@ -94,7 +189,7 @@ class ApiSecurityTest {
|
||||
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER"))
|
||||
.andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true))
|
||||
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
|
||||
.andExpect(jsonPath("$.capabilities.writeOrders").value(true));
|
||||
mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password")))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user"));
|
||||
verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any());
|
||||
@@ -148,7 +243,7 @@ class ApiSecurityTest {
|
||||
verify(database,never()).queryForList(anyString(),any(),any(),any());
|
||||
}
|
||||
|
||||
@Test void noWriteEndpointCanModifyTheDatabase() throws Exception {
|
||||
@Test void unsupportedMutationsRemainDisabled() throws Exception {
|
||||
MockHttpSession session = authenticated("existing-user","existing-password");
|
||||
Csrf csrf = csrf(session);
|
||||
mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token())
|
||||
@@ -157,6 +252,46 @@ class ApiSecurityTest {
|
||||
verify(database,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
|
||||
@Test void orderEntryHasItsOwnRightAndStillRequiresCsrf() throws Exception {
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("entry"),eq("password"),eq("password")))
|
||||
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","entry","emp_rights","0000000100")));
|
||||
var session=authenticated("entry","password");
|
||||
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.capabilities.createOrders").value(true)).andExpect(jsonPath("$.capabilities.readCustomers").value(false))
|
||||
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
|
||||
mvc.perform(get("/api/orders/entry/options").session(session)).andExpect(status().isOk());
|
||||
mvc.perform(get("/api/orders/entry/cost-centers").session(session)).andExpect(status().isOk());
|
||||
mvc.perform(get("/api/orders/entry/vehicles").session(session)).andExpect(status().isOk());
|
||||
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
|
||||
for(String endpoint:List.of("/api/orders","/api/orders/quote"))
|
||||
mvc.perform(post(endpoint).session(session).contentType("application/json").content(json.writeValueAsString(OrderEntryFixtures.request())))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void orderEntryRejectsUnknownServerOwnedFieldsAndInvalidCommands() throws Exception {
|
||||
var csrf=csrf(authenticated("existing-user","existing-password"));
|
||||
var input=json.valueToTree(OrderEntryFixtures.request());
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)input).put("hq_id",999);
|
||||
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
|
||||
.contentType("application/json").content(json.writeValueAsString(input))).andExpect(status().isBadRequest());
|
||||
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
|
||||
.contentType("application/json").content("{}")).andExpect(status().isBadRequest());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void orderEntryIsForbiddenWithoutRightSeven() throws Exception {
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("viewer"),eq("password"),eq("password")))
|
||||
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","viewer","emp_rights","1111111011")));
|
||||
var csrf=csrf(authenticated("viewer","password"));
|
||||
mvc.perform(get("/api/orders/entry/options").session(csrf.session())).andExpect(status().isForbidden());
|
||||
for(String endpoint:List.of("/api/orders","/api/orders/quote"))
|
||||
mvc.perform(post(endpoint).session(csrf.session()).header(csrf.header(),csrf.token())
|
||||
.contentType("application/json").content(json.writeValueAsString(OrderEntryFixtures.request())))
|
||||
.andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
@Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception {
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password")))
|
||||
.thenThrow(new DataAccessResourceFailureException("server secret must not be exposed"));
|
||||
@@ -238,4 +373,48 @@ class ApiSecurityTest {
|
||||
.andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED"))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe")));
|
||||
}
|
||||
@Test void inventoryAndMapEndpointsRequireEntryRightsAndStockRights() throws Exception {
|
||||
mvc.perform(get("/api/orders/entry/articles")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/orders/entry/features")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
mvc.perform(get("/api/orders/entry/features").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.bookStock").value(false));
|
||||
mvc.perform(get("/api/orders/entry/stocks").session(session).param("payerId","41").param("articleId","3")).andExpect(status().isForbidden());
|
||||
mvc.perform(post("/api/orders/entry/map/address").session(session).contentType("application/json").content("{\"latitude\":53,\"longitude\":9}")).andExpect(status().isForbidden());
|
||||
var csrf=csrf(session);
|
||||
mvc.perform(post("/api/orders/entry/map/address").session(session).header(csrf.header(),csrf.token()).contentType("application/json").content("{\"latitude\":91,\"longitude\":9}")).andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test void administrationAndStatisticsRequireSessionRightsAndCsrf() throws Exception {
|
||||
mvc.perform(get("/api/administration")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/administration/holidays/2026")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/statistics/options")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
mvc.perform(get("/api/administration").session(session)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.holidays").value(true)).andExpect(jsonPath("$.groups").value(false));
|
||||
mvc.perform(put("/api/administration/holidays/2026").session(session)
|
||||
.contentType("application/json").content("{}"))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
var token=csrf(session);
|
||||
mvc.perform(post("/api/administration/groups").session(session).header(token.header(),token.token())
|
||||
.contentType("application/json").content("{\"name\":\"x\",\"shared\":false}"))
|
||||
.andExpect(status().isForbidden());
|
||||
mvc.perform(get("/api/administration/travel-times").session(session).param("mode","unknown").param("from","1").param("to","2"))
|
||||
.andExpect(status().isBadRequest());
|
||||
verify(database,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
|
||||
@Test void appointmentsRequireSessionSalesRightsAndCsrf() throws Exception {
|
||||
mvc.perform(get("/api/appointments/options")).andExpect(status().isUnauthorized());
|
||||
var session=authenticated("existing-user","existing-password");
|
||||
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.readAppointments").value(true));
|
||||
for(String endpoint:List.of("/api/appointments","/api/appointments/query","/api/appointments/44/confirm","/api/appointments/44/finish","/api/appointments/44/resubmit"))
|
||||
mvc.perform(post(endpoint).session(session).contentType("application/json").content("{}"))
|
||||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
|
||||
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("sales-disabled"),eq("password"),eq("password")))
|
||||
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","sales-disabled","emp_rights","100000000000")));
|
||||
var restricted=authenticated("sales-disabled","password");
|
||||
mvc.perform(get("/api/appointments/options").session(restricted)).andExpect(status().isForbidden());
|
||||
verify(database,never()).writeTransaction(any());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.stadtbote.votian;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.net.*;import java.io.*;import java.nio.charset.StandardCharsets;import java.util.*;import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
class AppointmentMailTransportTest {
|
||||
@Test void deliversUtf8TextToOnlyExplicitEnvelopeRecipientsOnLocalSmtp() throws Exception {
|
||||
try(var server=new ServerSocket(0,1,InetAddress.getByName("127.0.0.1"));var executor=Executors.newSingleThreadExecutor()) {
|
||||
server.setSoTimeout(5000);var received=executor.submit(()->{var lines=new ArrayList<String>();try(var socket=server.accept()){socket.setSoTimeout(5000);var reader=new BufferedReader(new InputStreamReader(socket.getInputStream(),StandardCharsets.UTF_8));var writer=new PrintWriter(new OutputStreamWriter(socket.getOutputStream(),StandardCharsets.UTF_8),true);writer.print("220 localhost test\r\n");writer.flush();boolean data=false;for(String line;(line=reader.readLine())!=null;){lines.add(line);String response;if(data){if(!line.equals("."))continue;data=false;response="250 accepted";}else if(line.startsWith("DATA")){data=true;response="354 end with dot";}else if(line.startsWith("QUIT")){writer.print("221 bye\r\n");writer.flush();break;}else response="250 OK";writer.print(response+"\r\n");writer.flush();}}return lines;});
|
||||
var transport=new AppointmentMailTransport("127.0.0.1",server.getLocalPort(),"","",false,false,"");transport.send("actor@example.test","reply@example.test",List.of("one@example.test","two@example.test"),"Termin geändert","Grüße aus Berlin");var lines=received.get(10,TimeUnit.SECONDS);assertThat(lines).contains("RCPT TO:<one@example.test>","RCPT TO:<two@example.test>","MAIL FROM:<actor@example.test>");assertThat(lines).anyMatch(l->l.startsWith("Content-Type: text/plain;")&&l.contains("UTF-8"));assertThat(lines).noneMatch(l->l.startsWith("Bcc:"));
|
||||
}
|
||||
}
|
||||
@Test void deliversHtmlWithInlineImageAndPrivateBccThroughLocalSmtp() throws Exception {
|
||||
try(var server=new ServerSocket(0,1,InetAddress.getByName("127.0.0.1"));var executor=Executors.newSingleThreadExecutor()) {
|
||||
server.setSoTimeout(5000);var received=executor.submit(()->{var lines=new ArrayList<String>();try(var socket=server.accept()){socket.setSoTimeout(5000);var reader=new BufferedReader(new InputStreamReader(socket.getInputStream(),StandardCharsets.UTF_8));var writer=new PrintWriter(new OutputStreamWriter(socket.getOutputStream(),StandardCharsets.UTF_8),true);writer.print("220 localhost test\r\n");writer.flush();boolean data=false;for(String line;(line=reader.readLine())!=null;){lines.add(line);String response;if(data){if(!line.equals("."))continue;data=false;response="250 accepted";}else if(line.startsWith("DATA")){data=true;response="354 end with dot";}else if(line.startsWith("QUIT")){writer.print("221 bye\r\n");writer.flush();break;}else response="250 OK";writer.print(response+"\r\n");writer.flush();}}return lines;});
|
||||
var transport=new AppointmentMailTransport("127.0.0.1",server.getLocalPort(),"","",false,false,"");
|
||||
transport.sendHtml("actor@example.test","reply@example.test","to@example.test",List.of("cc@example.test"),List.of("hidden@example.test"),"Newsletter", "<h1>Abholung</h1><img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='>");
|
||||
var lines=received.get(10,TimeUnit.SECONDS);assertThat(lines).contains("RCPT TO:<to@example.test>","RCPT TO:<cc@example.test>","RCPT TO:<hidden@example.test>","MAIL FROM:<actor@example.test>");assertThat(lines).noneMatch(l->l.startsWith("Bcc:"));
|
||||
int begin=lines.indexOf("DATA")+1;int end=lines.subList(begin,lines.size()).indexOf(".")+begin;
|
||||
var message=new jakarta.mail.internet.MimeMessage(jakarta.mail.Session.getInstance(new Properties()),new ByteArrayInputStream(String.join("\r\n",lines.subList(begin,end)).getBytes(StandardCharsets.UTF_8)));
|
||||
assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.TO)).hasSize(1);assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.CC)).hasSize(1);assertThat(message.getRecipients(jakarta.mail.Message.RecipientType.BCC)).isNull();
|
||||
var parts=new ArrayList<jakarta.mail.Part>();collect(message,parts);
|
||||
assertThat(parts.stream().filter(p->{try{return p.isMimeType("text/html");}catch(Exception e){throw new RuntimeException(e);}}).toList()).singleElement().satisfies(p->{try{assertThat(p.getContent().toString()).contains("cid:newsletter-image-0").doesNotContain("data:image");}catch(Exception e){throw new RuntimeException(e);}});
|
||||
assertThat(parts.stream().filter(p->{try{return p.isMimeType("image/png");}catch(Exception e){throw new RuntimeException(e);}}).toList()).singleElement().satisfies(p->{try{assertThat(p.getHeader("Content-ID")).containsExactly("<newsletter-image-0>");assertThat(p.getInputStream().readAllBytes()).hasSizeGreaterThan(50);}catch(Exception e){throw new RuntimeException(e);}});
|
||||
}
|
||||
}
|
||||
static void collect(jakarta.mail.Part part,List<jakarta.mail.Part> parts)throws Exception{if(part.isMimeType("multipart/*")){var multi=(jakarta.mail.Multipart)part.getContent();for(int i=0;i<multi.getCount();i++)collect(multi.getBodyPart(i),parts);}else parts.add(part);}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.util.*;import java.time.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
class AppointmentNotificationsTest {
|
||||
final DatabaseService db=mock(DatabaseService.class);final AppointmentService appointments=mock(AppointmentService.class);final AppointmentMailTransport transport=spy(new AppointmentMailTransport("127.0.0.1",1,"","",false,false,""));final AppointmentNotifications service=new AppointmentNotifications(db,appointments,transport);final LegacyUser user=AppointmentServiceTest.user(0,11);
|
||||
final Map<String,Object> row=fields("ap_id",44L,"usr_id",11L,"ap_participants",",11,22,33,","ap_text","<script>Plain text only</script>","ap_execdate",LocalDateTime.of(2026,10,13,9,0),"ap_enddate",LocalDateTime.of(2026,10,13,10,0));
|
||||
AppointmentNotifications.Draft draft(){return new AppointmentNotifications.Draft(AppointmentNotifications.Kind.CHANGED,true,"TEST@example.test,external@example.test","Hallo","Viele Grüße");}
|
||||
@BeforeEach void setup(){when(appointments.appointment(user,44)).thenReturn(row);when(db.queryForList(startsWith("SELECT usr_email"),any(Object[].class))).thenReturn(List.of(fields("usr_email","test@example.test")));when(db.queryForList(startsWith("SELECT DISTINCT u.usr_id"),any(Object[].class))).thenReturn(List.of(fields("usr_id",11L,"usr_email","test@example.test","label","Ich"),fields("usr_id",22L,"usr_email","person@example.test","label","Teilnehmer"),fields("usr_id",33L,"usr_email","","label","Ohne Mail")));doNothing().when(transport).send(anyString(),anyString(),anyList(),anyString(),anyString());}
|
||||
@Test void previewDeduplicatesRecipientsAndReportsMissingAddressesWithoutSending(){var p=service.preview(user,44,draft());assertThat((List<?>)p.get("recipients")).hasSize(3);assertThat((List<String>)p.get("missing")).containsExactly("Ohne Mail");assertThat(p.get("body").toString()).contains("Plain text only");verify(transport,never()).send(anyString(),anyString(),anyList(),anyString(),anyString());}
|
||||
@Test void uncheckedParticipantsAreNotSentExternalOnlyNotification(){var p=service.preview(user,44,new AppointmentNotifications.Draft(AppointmentNotifications.Kind.NEW,false,"external@example.test","",""));assertThat((List<String>)p.get("recipients")).containsExactly("external@example.test");}
|
||||
@Test void headerInjectionAndAddressListsInSingleAddressAreRejected(){for(String value:List.of("x@example.test\r\nBcc:evil@example.test","a@example.test,b@example.test","Name <a@example.test>","not-an-address"))assertThatThrownBy(()->AppointmentNotifications.address(value)).hasMessageContaining("400");}
|
||||
@Test void repeatedSendOfSamePreviewDoesNotDuplicateMail(){var p=service.preview(user,44,draft());String token=(String)p.get("token");assertThat(service.send(user,token)).containsEntry("sent",true);service.send(user,token);verify(transport,times(1)).send(anyString(),anyString(),anyList(),anyString(),anyString());verify(db,never()).update(anyString(),any(Object[].class));}
|
||||
@Test void failedSendNeverRewritesAppointmentOrAutomaticallyRetries(){doThrow(new org.springframework.mail.MailSendException("private relay details")).when(transport).send(anyString(),anyString(),anyList(),anyString(),anyString());String token=(String)service.preview(user,44,draft()).get("token");var r=service.send(user,token);assertThat(r).containsEntry("sent",false);assertThat(r.toString()).doesNotContain("private relay");service.send(user,token);verify(transport,times(1)).send(anyString(),anyString(),anyList(),anyString(),anyString());verify(db,never()).writeTransaction(any());}
|
||||
@Test void changedAppointmentRequiresNewPreview(){String token=(String)service.preview(user,44,draft()).get("token");row.put("ap_text","changed");assertThatThrownBy(()->service.send(user,token)).hasMessageContaining("409");verify(transport,never()).send(anyString(),anyString(),anyList(),anyString(),anyString());}
|
||||
@Test void previewCannotBeUsedByOtherAccountAndCancellationRequiresDeleteRight(){String token=(String)service.preview(user,44,draft()).get("token");var other=new LegacyUser(99,99,7,"other",user.rights());assertThatThrownBy(()->service.send(other,token)).hasMessageContaining("400");row.put("usr_id",22L);assertThatThrownBy(()->service.preview(user,44,new AppointmentNotifications.Draft(AppointmentNotifications.Kind.CANCELLED,true,"","",""))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.AppointmentService.*;
|
||||
|
||||
class AppointmentServiceTest {
|
||||
final DatabaseService db=mock(DatabaseService.class);
|
||||
final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);
|
||||
final AppointmentService service=new AppointmentService(db,catalog);
|
||||
final LegacyUser user=user(0,11),master=user(0,3,10,11);
|
||||
final LocalDate date=LocalDate.of(2026,10,12);
|
||||
final List<String> queries=new ArrayList<>();
|
||||
final List<List<Object>> arguments=new ArrayList<>();
|
||||
Map<String,Object> row=fields("ap_id",44L,"hq_id",7L,"usr_id",11L,"ap_type",0,"ap_category_1",1,"ap_category_2",1,"ap_category_3",2,"ap_category_4",1,"ap_participants",",11,22,","ap_text","Besprechung","ap_execdate",date.atTime(9,0),"ap_enddate",date.atTime(10,0),"ap_confirmed",",22,","ap_modifytime",date.atTime(8,0),"cs_id",9L,"csc_id",0,"pt_id",0,"customer_label","Testkunde");
|
||||
boolean missing=false,reportExists=false;
|
||||
Map<String,Object> inserted;String updatedSql;List<Object> updatedArgs;
|
||||
static LegacyUser user(int... rights) { char[] bits="0".repeat(70).toCharArray();for(int r:rights)bits[r]='1';return new LegacyUser(11,12,7,"test",new String(bits)); }
|
||||
Query query() { return new Query(date,date,List.of(7L),null,null,null,null,null,List.of(Visibility.values()),""); }
|
||||
Save save() { return new Save(AdminSupport.revision(version(row)),date.atTime(9,0),date.atTime(10,0),"Neu",1,1,2,1,9L,List.of(11L,22L)); }
|
||||
Save with(Save r,LocalDateTime from,LocalDateTime to,int monitor,Long customer,List<Long> people,String text) { return new Save(r.revision(),from,to,text,r.status(),r.visibility(),r.kind(),monitor,customer,people); }
|
||||
@BeforeEach void setup() {
|
||||
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
|
||||
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
|
||||
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{
|
||||
String sql=c.getArgument(0);var args=Arrays.asList(c.getArguments()).subList(1,c.getArguments().length);queries.add(sql);arguments.add(args);
|
||||
if(sql.startsWith("SELECT mt_sort"))return args.getFirst().equals("appointment_category_4")?List.of(fields("id",0L,"label","Keine"),fields("id",1L,"label","Aktiv")):List.of(fields("id",1L,"label","Eins"),fields("id",2L,"label","Zwei"),fields("id",3L,"label","Drei"));
|
||||
if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));
|
||||
if(sql.startsWith("SELECT u.usr_id AS id"))return List.of(fields("id",11L,"label","Tester","groups",",24,"),fields("id",22L,"label","Teilnehmer","groups",",24,"));
|
||||
if(sql.startsWith("SELECT DISTINCT u.usr_id"))return List.of(fields("id",11L,"label","Tester"),fields("id",22L,"label","Teilnehmer"));
|
||||
if(sql.startsWith("SELECT cs_id FROM"))return List.of(fields("cs_id",9L));
|
||||
if(sql.startsWith("SELECT md_id"))return List.of(fields("md_id",1L));
|
||||
if(sql.startsWith("SELECT grp_id"))return List.of(fields("id",24L,"label","Dispo"));
|
||||
if(sql.startsWith("SELECT a.ap_category_1"))return List.of(fields("ap_category_1",1,"ap_category_2",1,"ap_category_3",2,"ap_category_4",1,"total",3));
|
||||
if(sql.startsWith("SELECT a.ap_id"))return missing?List.of():List.of(row);
|
||||
if(sql.startsWith("SELECT rp_id"))return reportExists?List.of(fields("rp_id",1L)):List.of();
|
||||
return List.of();
|
||||
});
|
||||
when(db.insert(anyString(),anyMap())).thenAnswer(c->{inserted=new LinkedHashMap<>(c.getArgument(1));return 45L;});
|
||||
when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{updatedSql=c.getArgument(0);updatedArgs=Arrays.asList(c.getArguments()).subList(1,c.getArguments().length);return 1;});
|
||||
}
|
||||
@Test void bothSalesAndCustomerRightsRequiredBeforeDataAccess() {
|
||||
for(var who:List.of(user(0),user(11))) {
|
||||
assertThatThrownBy(()->service.list(who,query())).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(()->service.create(who,save())).isInstanceOf(AccessDeniedException.class);
|
||||
assertThat(who.capabilities().readAppointments()).isFalse();
|
||||
}
|
||||
verifyNoInteractions(db,catalog);assertThat(user.capabilities().readAppointments()).isTrue();
|
||||
}
|
||||
@Test void privacyRemainsMandatoryEvenForMasterAndAllVisibilityFilters() {
|
||||
for(var who:List.of(user,master)) {service.list(who,query());service.statistics(who,query());}
|
||||
assertThat(queries.stream().filter(q->q.contains("FROM "+TABLE))).hasSize(4).allMatch(q->q.contains("(a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))"));
|
||||
assertThat(arguments.stream().filter(a->a.contains("%,11,%"))).hasSize(4);
|
||||
}
|
||||
@Test void overlapsAndPersonFiltersUseBoundWholeIds() {
|
||||
var q=new Query(date,date,List.of(7L),22L,9L,1,2,1,List.of(Visibility.INVITED),"' OR 1=1");
|
||||
service.list(user,q);
|
||||
int i=queries.size()-2; // participant name lookup follows the calendar SELECT
|
||||
assertThat(queries.get(i)).contains("a.ap_execdate<? AND (a.ap_execdate>=? OR a.ap_enddate>=?)","a.cs_id=?","a.ap_category_3=?","a.ap_text LIKE ?").doesNotContain("' OR 1=1");
|
||||
assertThat(arguments.get(i)).contains("%,22,%","%' OR 1=1%",date.plusDays(1).atStartOfDay(),date.atStartOfDay());
|
||||
}
|
||||
@Test void crossHeadquartersRequiresMasterWithinSameMandator() {
|
||||
var q=new Query(date,date,List.of(8L),null,null,null,null,null,query().visibility(),"");
|
||||
assertThatThrownBy(()->service.list(user,q)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(()->service.list(master,new Query(date,date,List.of(99L),null,null,null,null,null,query().visibility(),""))).isInstanceOf(AccessDeniedException.class);
|
||||
assertThat(queries).noneMatch(s->s.contains("FROM "+TABLE));
|
||||
service.list(master,q);
|
||||
}
|
||||
@Test void emptyAndInvalidFiltersNeverBroadenToAllAppointments() {
|
||||
for(var q:List.of(new Query(date,date,List.of(),null,null,null,null,null,query().visibility(),""),new Query(date,date,List.of(7L),null,null,null,null,null,List.of(),""),new Query(date,date.plusDays(366),List.of(7L),null,null,null,null,null,query().visibility(),"")))assertThatThrownBy(()->service.list(user,q)).hasMessageContaining("400");
|
||||
assertThat(queries).noneMatch(s->s.contains("FROM "+TABLE));
|
||||
validate(new Query(LocalDate.of(2024,1,1),LocalDate.of(2024,12,31),List.of(7L),null,null,null,null,null,query().visibility(),""),false);
|
||||
}
|
||||
@Test void statisticsCountsAppointmentsOnceAndIncludesUnusedCategories() {
|
||||
var result=service.statistics(user,query());
|
||||
assertThat(result).containsEntry("total",3L);
|
||||
@SuppressWarnings("unchecked") var categories=(List<Map<String,Object>>)result.get("categories");
|
||||
assertThat(categories).hasSize(4);assertThat(categories.get(0).get("items").toString()).contains("total=0","total=3");
|
||||
assertThat(queries).anyMatch(s->s.contains("COUNT(*) AS total")&&s.contains("GROUP BY a.ap_category_1"));
|
||||
}
|
||||
@Test void optionsIncludesRealEmployeeGroupsAndCompletedControl() {
|
||||
var result=service.options(user);
|
||||
assertThat(result.get("groups").toString()).contains("Dispo","11","22");
|
||||
assertThat(result.get("categories").toString()).contains("Erledigt");
|
||||
}
|
||||
@Test void createsInSessionHeadquartersWithNoInheritedConfirmations() {
|
||||
assertThat(service.create(user,save())).containsEntry("id",45L);
|
||||
assertThat(inserted).containsEntry("usr_id",11L).containsEntry("hq_id",7L).containsEntry("ap_confirmed","").containsEntry("ap_participants",",11,22,").containsEntry("csc_id",null);
|
||||
verify(db,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
@Test void malformedTimesUnknownParticipantsAndLargeUtf8TextCannotBeSaved() {
|
||||
var s=save();
|
||||
for(var invalid:List.of(with(s,s.endsAt(),s.startsAt(),1,9L,s.participants(),"x"),with(s,s.startsAt().plusSeconds(1),s.endsAt(),1,9L,s.participants(),"x"),with(s,s.startsAt(),s.endsAt(),1,9L,List.of(999L),"x"),with(s,s.startsAt(),s.endsAt(),1,9L,s.participants(),"€".repeat(22000)),with(s,s.startsAt(),s.endsAt(),2,9L,s.participants(),"x")))assertThatThrownBy(()->service.create(user,invalid)).isInstanceOf(RuntimeException.class);
|
||||
verify(db,never()).insert(anyString(),anyMap());
|
||||
}
|
||||
@Test void participantMayEditButCannotDeleteAnotherAuthorsAppointment() {
|
||||
row.put("usr_id",22L);service.save(user,44,save());
|
||||
assertThatThrownBy(()->service.remove(user,44,save().revision())).isInstanceOf(AccessDeniedException.class);
|
||||
row.put("ap_participants",",22,");assertThatThrownBy(()->service.save(user,44,save())).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
@Test void editorPreservesAuthorHeadquartersAndHiddenContactLinks() {
|
||||
row.put("csc_id",81L);row.put("pt_id",83L);service.save(master,44,save());
|
||||
String set=updatedSql.substring(0,updatedSql.indexOf(" WHERE "));
|
||||
assertThat(set).doesNotContain("usr_id=","hq_id=","csc_id=","pt_id=");
|
||||
var s=save();assertThatThrownBy(()->service.save(master,44,with(s,s.startsAt(),s.endsAt(),1,null,s.participants(),"x"))).hasMessageContaining("Kontaktverknüpfung");
|
||||
}
|
||||
@Test void movingTimeClearsConfirmationButTextOnlyEditRetainsIt() {
|
||||
service.save(user,44,save());assertThat(updatedArgs.get(9)).isEqualTo(",22,");
|
||||
var s=save();service.save(user,44,with(s,s.startsAt().plusDays(1),s.endsAt().plusDays(1),1,9L,s.participants(),"x"));assertThat(updatedArgs.get(9)).isEqualTo("");
|
||||
}
|
||||
@Test void removedParticipantLosesConfirmationAndOldUnavailableParticipantsCanBePreserved() {
|
||||
var s=save();service.save(user,44,with(s,s.startsAt(),s.endsAt(),1,9L,List.of(11L),"x"));assertThat(updatedArgs.get(9)).isEqualTo("");
|
||||
row.put("ap_participants",",11,999,");s=save();service.save(user,44,with(s,s.startsAt(),s.endsAt(),1,9L,List.of(11L,999L),"x"));assertThat(updatedArgs.get(8)).isEqualTo(",11,999,");
|
||||
}
|
||||
@Test void staleRevisionOrConcurrentLegacyMutationCannotOverwriteData() {
|
||||
var s=save();assertThatThrownBy(()->service.save(user,44,new Save("old",s.startsAt(),s.endsAt(),"x",1,1,2,1,9L,s.participants()))).hasMessageContaining("409");
|
||||
when(db.update(anyString(),any(Object[].class))).thenReturn(0);
|
||||
assertThatThrownBy(()->service.save(user,44,save())).hasMessageContaining("409");
|
||||
assertThatThrownBy(()->service.remove(user,44,save().revision())).hasMessageContaining("409");
|
||||
verify(db,times(2)).update(contains("BINARY ap_text <=> BINARY ?"),any(Object[].class)); // UPDATE and DELETE both use every original column
|
||||
}
|
||||
@Test void inaccessibleAppointmentsCannotBeEditedEvenWithAdministrativeRights() {
|
||||
missing=true;assertThatThrownBy(()->service.save(master,44,save())).hasMessageContaining("404");
|
||||
assertThat(queries).anyMatch(s->s.contains("a.ap_id=? AND a.hq_id IN (?,?) AND (a.usr_id=? OR a.ap_category_2=3"));
|
||||
verify(db,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
@Test void confirmsOnlyCurrentParticipantAndKeepsOtherConfirmations() {
|
||||
service.confirm(user,44,save().revision());assertThat(updatedArgs.getFirst()).isEqualTo(",11,22,");
|
||||
row.put("ap_participants",",22,");assertThatThrownBy(()->service.confirm(master,44,save().revision())).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
@Test void finishRequiresReportWhenConfiguredAndRejectsInactiveAppointments() {
|
||||
when(catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")).thenReturn(true);
|
||||
assertThatThrownBy(()->service.finish(user,44,save().revision())).hasMessageContaining("Kundenbericht");
|
||||
verify(db,never()).update(anyString(),any(Object[].class));
|
||||
reportExists=true;service.finish(user,44,save().revision());assertThat(updatedArgs.getFirst()).isEqualTo(2);
|
||||
assertThat(queries).anyMatch(s->s.contains("rp_objid=? AND hq_id=?")&&s.contains("rp_confidential=0 OR usr_id=?"));
|
||||
row.put("ap_category_4",0);assertThatThrownBy(()->service.finish(user,44,save().revision())).hasMessageContaining("überwachter Termin");
|
||||
}
|
||||
@Test void editorCannotBypassReportRequirementByFinishingAnUnmonitoredAppointment() {
|
||||
row.put("ap_category_4",0);when(catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")).thenReturn(true);
|
||||
var s=save();assertThatThrownBy(()->service.save(user,44,with(s,s.startsAt(),s.endsAt(),2,9L,s.participants(),"x"))).hasMessageContaining("Kundenbericht");
|
||||
verify(db,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
@Test void resubmissionPreservesSourceRelationsResetsConfirmationsAndReactivatesMonitoring() {
|
||||
row.put("ap_category_4",2);row.put("csc_id",8L);row.put("pt_id",6L);
|
||||
var result=service.resubmit(user,44,new Resubmission(save().revision(),date.plusDays(7).atTime(9,0),date.plusDays(7).atTime(10,0)));
|
||||
assertThat(result).containsEntry("id",45L);assertThat(inserted).doesNotContainKey("ap_id").containsEntry("csc_id",8L).containsEntry("pt_id",6L).containsEntry("ap_confirmed","").containsEntry("ap_category_4",1).containsEntry("usr_id",11L);
|
||||
verify(db,never()).update(anyString(),any(Object[].class));assertThat(row).containsEntry("ap_confirmed",",22,");
|
||||
}
|
||||
@Test void legacyZeroCustomerIsReturnedAsNoAssignment() {
|
||||
row.put("cs_id",0);@SuppressWarnings("unchecked") var items=(List<Map<String,Object>>)service.list(user,query()).get("items");assertThat(items.getFirst()).containsEntry("customerId",null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
|
||||
/** End-to-end business writes against disposable MariaDB, never the legacy host. */
|
||||
public class BusinessIntegrationAssertions {
|
||||
static String token(){return UUID.randomUUID().toString();}
|
||||
@SuppressWarnings("unchecked") static Map<String,Object> values(Map<String,Object> view){return new LinkedHashMap<>((Map<String,Object>)view.get("values"));}
|
||||
static MasterData.Save save(Map<String,Object> view,Map<String,Object> values){return new MasterData.Save(token(),text(view,"revision"),values);}
|
||||
@SuppressWarnings("unchecked") public static void verify(DatabaseService db){
|
||||
var u=AppointmentServiceTest.user(0,1,2,4,5,7,38,41);var support=new BusinessSupport(db,new ObjectMapper().findAndRegisterModules());var masters=new MasterData(db,support);
|
||||
var blank=masters.view(u,"customers",0,0);var input=values(blank);input.putAll(fields("cmp_comp","QA Kunde","cs_eid","QA001","ad_street","Teststraße","cmp_hsno","1","ad_zipcode","10115","ad_city","Berlin","usr_name","Test","usr_phone","030123","usr_inv_email","test@example.invalid"));
|
||||
var create=save(blank,input);var customer=masters.save(u,"customers",0,0,create);long cs=id(customer,"id");assertThat(masters.save(u,"customers",0,0,create)).containsEntry("id",cs);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM customer",Integer.class)).isEqualTo(1);
|
||||
long center=id(db.queryForList("SELECT csc_id FROM customer WHERE cs_id=?",cs).getFirst(),"csc_id");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM costcenteraddress WHERE csc_id=?",Integer.class,center)).isEqualTo(3);
|
||||
long originalAddress=id(db.queryForList("SELECT ad_id FROM company WHERE cmp_comp='QA Kunde'").getFirst(),"ad_id");input=values(customer);input.put("ad_street","Neue Straße");var changed=masters.save(u,"customers",cs,0,save(customer,input));assertThat(text(db.queryForList("SELECT ad_street FROM address WHERE ad_id=?",originalAddress).getFirst(),"ad_street")).isEqualTo("Teststraße");
|
||||
final var oldCustomer=customer;final var staleValues=input;assertThatThrownBy(()->masters.save(u,"customers",cs,0,save(oldCustomer,staleValues))).hasMessageContaining("409");
|
||||
var cview=masters.view(u,"centers",center,cs);var cvalues=values(cview);cvalues.put("addr2_cscad_comp","Rechnungsempfänger");masters.save(u,"centers",center,cs,save(cview,cvalues));assertThat(text(db.queryForList("SELECT cscad_comp FROM costcenteraddress WHERE csc_id=? AND adt_id=2",center).getFirst(),"cscad_comp")).isEqualTo("Rechnungsempfänger");
|
||||
var childBlank=masters.view(u,"centers",0,cs);var childValues=values(childBlank);childValues.put("csc_name","Zweigstelle");var child=masters.save(u,"centers",0,cs,save(childBlank,childValues));assertThat(db.queryForList("SELECT csc_path FROM costcenter WHERE csc_id=?",id(child,"id")).getFirst().get("csc_path")).isEqualTo("//QA Kunde//");
|
||||
var listing=new LegacyReadService(db,500);
|
||||
assertThat(listing.customers(u,"QA",false,null)).hasSize(1);
|
||||
assertThat(listing.customers(u,"QA",true,null)).hasSize(2);
|
||||
assertThat(listing.customers(u,"QA",true,"Zweig")).singleElement().satisfies(row->{assertThat(row.id).isEqualTo(cs);assertThat(row.costCenterId).isEqualTo(id(child,"id"));assertThat(row.costCenterName).isEqualTo("Zweigstelle");});
|
||||
assertThat(listing.customers(u,"QA",true,"' OR 1=1 --")).isEmpty();
|
||||
assertThat(listing.customers(new LegacyUser(22,22,8,"foreign","1".repeat(70)),"QA",true,"Zweig")).isEmpty();
|
||||
childValues=values(child);childValues.put("csc_pre_id",id(child,"id"));var cycle=save(child,childValues);assertThatThrownBy(()->masters.save(u,"centers",id(child,"id"),cs,cycle)).hasMessageContaining("Kreis");
|
||||
var crblank=masters.view(u,"couriers",0,0);var crvalues=values(crblank);crvalues.putAll(fields("cmp_comp","QA Kurier","cr_eid","QACR01","cmp_hsno","2","usr_name","Kurier","usr_phone","030123","ad_street","Testweg","ad_zipcode","10115","ad_city","Berlin"));var courier=masters.save(u,"couriers",0,0,save(crblank,crvalues));long cr=id(courier,"id");
|
||||
var vblank=masters.view(u,"vehicles",0,cr);var vvalues=values(vblank);vvalues.putAll(fields("crvh_sid","QA01","vht_id",1));var vehicle=masters.save(u,"vehicles",0,cr,save(vblank,vvalues));assertThat(id(vehicle,"id")).isPositive();
|
||||
long address=originalAddress;long job=db.writeTransaction(()->{long j=db.insert("job",fields("hq_id",7,"csc_id_payer",center,"jb_status",1,"cr_id",cr,"cr_id_order",cr,"cr_sid","QA01","jb_createtime",LocalDateTime.now(),"jb_ordertime",LocalDateTime.now().minusHours(1),"jb_freetext_1","","jb_service","","jb_orderer","","jb_scrm","","jb_totalprice",100,"jb_cr_price",80,"jb_sales_tax_rate",19,"jb_payment",0,"jb_postage",0));for(int i=1;i<=2;i++)db.insert("tour",fields("jb_id",j,"tr_sort",i,"ad_id",address,"tr_comp","Station "+i,"tr_ware_from_to",i==1?2:1,"tr_status",i==1?1:0,"tr_signname",i==1?"Originalempfänger":"","tr_cs_freetext",""));BusinessSupport.insertWithoutKey(db,"tourservice",fields("jb_id",j,"csc_id",center,"tr_sort",0,"srv_id",0,"srvt_id",0,"trs_srv_name","Fixpreis","trs_srvt_name","","trs_price",100,"trs_discount",0));return j;});
|
||||
var catalog=new OrderEntryCatalog(db);var ops=new OrderOperations(db,support,catalog,new LegacyOrderPricing(db,catalog));var work=ops.view(u,job);var vals=values(work);vals.put("jb_orderer","Geändert");var stations=((List<Map<String,Object>>)work.get("stations")).stream().map(st->new OrderOperations.Station(id(st,"id"),values(st))).toList();var edit=new OrderOperations.Edit(token(),text(work,"revision"),vals,stations);ops.edit(u,job,edit);ops.edit(u,job,edit);assertThatThrownBy(()->ops.edit(u,job,new OrderOperations.Edit(token(),text(work,"revision"),vals,stations))).hasMessageContaining("409");
|
||||
var recalc=new OrderRecalculation(db,support,ops,catalog,new LegacyOrderPricing(db,catalog),mock(OrderRoutingService.class),jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator());
|
||||
db.writeTransaction(()->{OrderPaymentTerms.capture(db,job,cs);db.update("INSERT INTO jobprice(jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,16,12.5,'')",job);return null;});
|
||||
var recalcInput=new OrderRecalculation.Input(new OrderEntryModels.Pricing(OrderEntryModels.PriceMode.FIXED,new BigDecimal("130.00"),BigDecimal.ZERO,new BigDecimal("85.00"),BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,false,1L),1,List.of(),false,false);
|
||||
var recalcDraft=new OrderRecalculation.Command(token(),text(ops.view(u,job),"revision"),recalcInput,null);var recalcPreview=recalc.preview(u,job,recalcDraft);
|
||||
var recalcSave=new OrderRecalculation.Command(recalcDraft.requestId(),recalcDraft.revision(),recalcInput,text(recalcPreview,"fingerprint"));
|
||||
recalc.save(u,job,recalcSave);recalc.save(u,job,recalcSave);
|
||||
assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",job).getFirst(),"jb_totalprice")).isEqualByComparingTo("130");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM job",Integer.class)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM tour WHERE jb_id=?",Integer.class,job)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT jbprc_price FROM jobprice WHERE jb_id=? AND mt_sort=16",BigDecimal.class,job)).isEqualByComparingTo("12.5");
|
||||
assertThat(OrderPaymentTerms.read(db,job)).containsEntry("label","10 Tage netto");
|
||||
var recalcOptions=recalc.options(u,job);
|
||||
var fresh=ops.view(u,job);var price=new OrderOperations.Price(token(),text(fresh,"revision"),new BigDecimal("125.50"),new BigDecimal("85.00"),"Testkorrektur");ops.price(u,job,price);ops.price(u,job,price);assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",job).getFirst(),"jb_totalprice")).isEqualByComparingTo("125.50");
|
||||
try{support.json.writeValue(new java.io.File("target/business-ui-fixture.json"),fields("customer",masters.view(u,"customers",cs,0),"courier",masters.view(u,"couriers",cr,0),"center",masters.view(u,"centers",center,cs),"child",masters.view(u,"centers",id(child,"id"),cs),"vehicle",masters.view(u,"vehicles",id(vehicle,"id"),cr),"order",ops.view(u,job),"calculation",recalcOptions,"calculationPreview",recalcPreview));}catch(java.io.IOException e){throw new IllegalStateException(e);}
|
||||
var done=new OrderOperations.Action(token(),text(ops.view(u,job),"revision"),"COMPLETE",null,null,"Neuer Empfänger",null,false,false);ops.action(u,job,done);ops.action(u,job,done);assertThat(text(db.queryForList("SELECT tr_signname FROM tour WHERE jb_id=? AND tr_sort=1",job).getFirst(),"tr_signname")).isEqualTo("Originalempfänger");assertThat(id(db.queryForList("SELECT jb_status FROM job WHERE jb_id=?",job).getFirst(),"jb_status")).isEqualTo(2);
|
||||
var billing=new BillingService(db,support);var q=new BillingService.Query(LocalDate.now().minusDays(1),LocalDate.now(),"CUSTOMER",cs,null,true,null,"",false);var statement=billing.query(u,q);assertThat((BigDecimal)statement.get("net")).isEqualByComparingTo("125.50");assertThat((BigDecimal)statement.get("gross")).isEqualByComparingTo("149.35");
|
||||
var assignment=new BillingService.Assign(token(),q,text(statement,"revision"),List.of(job),"QA-2026-001",LocalDate.now());
|
||||
// Invoice uses MyISAM: simulate losing acknowledgement after the insert, then safely resume.
|
||||
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("lost acknowledgement")).when(failing).update(startsWith("UPDATE genericdatacontainer SET gdc_context='DONE'"),any(Object[].class));var broken=new BillingService(failing,new BusinessSupport(failing,support.json));assertThatThrownBy(()->broken.assign(u,assignment)).hasMessageContaining("lost acknowledgement");assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,job)).isEqualTo(1);billing.assign(u,assignment);billing.assign(u,assignment);assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,job)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_gen_fieldname='ng_invoice_lock'",Integer.class)).isZero();
|
||||
assertThatThrownBy(()->ops.price(u,job,new OrderOperations.Price(token(),text(ops.view(u,job),"revision"),BigDecimal.ONE,BigDecimal.ONE,"Nach Rechnung"))).hasMessageContaining("bereits exportiert");
|
||||
try{java.nio.file.Files.write(java.nio.file.Path.of("target/business-statement.pdf"),StatementPdf.create(billing.query(u,q),q));java.nio.file.Files.write(java.nio.file.Path.of("target/business-statement.csv"),billing.csv(u,q));}catch(java.io.IOException e){throw new IllegalStateException(e);}
|
||||
var credit=new OrderOperations.Action(token(),text(ops.view(u,job),"revision"),"CREDIT",null,null,null,"Testgutschrift",false,false);var credited=ops.action(u,job,credit);long negative=id(credited,"resultId");assertThat(negative).isNotEqualTo(job);assertThat(ops.action(u,job,credit)).containsEntry("resultId",negative);assertThat(decimal(db.queryForList("SELECT jb_totalprice FROM job WHERE jb_id=?",negative).getFirst(),"jb_totalprice")).isEqualByComparingTo("-125.50");assertThat((BigDecimal)billing.query(u,q).get("net")).isEqualByComparingTo("0.00");
|
||||
assertThatThrownBy(()->masters.view(new LegacyUser(22,22,8,"foreign","1".repeat(70)),"customers",cs,0)).hasMessageContaining("404");
|
||||
support.publish();long logs=db.queryForObject("SELECT COUNT(*) FROM phoenix_log.log",Long.class);assertThat(logs).isPositive();support.publish();assertThat(db.queryForObject("SELECT COUNT(*) FROM phoenix_log.log",Long.class)).isEqualTo(logs);
|
||||
// A main-transaction failure must not leave a changed master record or a partial price rewrite.
|
||||
var rejectReceipt=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("write failure")).when(rejectReceipt).insert(eq("genericdatacontainer"),anyMap());var rollbackMasters=new MasterData(rejectReceipt,new BusinessSupport(rejectReceipt,support.json));var currentMaster=masters.view(u,"customers",cs,0);var revisedValues=values(currentMaster);revisedValues.put("cmp_comp","Must roll back");assertThatThrownBy(()->rollbackMasters.save(u,"customers",cs,0,save(currentMaster,revisedValues))).hasMessageContaining("write failure");assertThat(values(masters.view(u,"customers",cs,0)).get("cmp_comp")).isEqualTo("QA Kunde");
|
||||
// Detect an old-PHP edit between durable intent and a resumed MyISAM assignment.
|
||||
var before=billing.query(u,q);var next=new BillingService.Assign(token(),q,text(before,"revision"),List.of(negative),"QA-CREDIT",LocalDate.now());var interrupted=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("invoice unavailable")).when(interrupted).update(startsWith("INSERT INTO invoice"),any(Object[].class));var interruptedBilling=new BillingService(interrupted,new BusinessSupport(interrupted,support.json));assertThatThrownBy(()->interruptedBilling.assign(u,next)).hasMessageContaining("invoice unavailable");db.writeTransaction(()->db.update("UPDATE job SET jb_incomplete=1 WHERE jb_id=?",negative));assertThatThrownBy(()->billing.resume(u,next.requestId())).hasMessageContaining("seit Beginn");assertThat(db.queryForObject("SELECT COUNT(*) FROM invoice WHERE jb_id=?",Integer.class,negative)).isZero();billing.stop(u,next.requestId());assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_gen_fieldname='ng_invoice_lock'",Integer.class)).isZero();
|
||||
assertThat(new String(billing.csv(u,q),java.nio.charset.StandardCharsets.UTF_8)).contains("QA-2026-001","125.50");var pdf=StatementPdf.create(billing.query(u,q),q);assertThat(new String(pdf,StatementPdf.WIN)).startsWith("%PDF-1.4").contains("xref","Rechnungsanlage");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.stadtbote.votian;
|
||||
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static de.stadtbote.votian.CalendarAdministration.*;
|
||||
|
||||
class CalendarAdministrationTest {
|
||||
final DatabaseService db=mock(DatabaseService.class);
|
||||
final LegacyUser user=new LegacyUser(11,12,7,"test","1".repeat(70));
|
||||
final CalendarAdministration service=new CalendarAdministration(db,Clock.fixed(Instant.parse("2026-10-12T09:00:00Z"),ZoneId.of("Europe/Berlin")));
|
||||
final List<Map<String,Object>> holidays=new ArrayList<>();
|
||||
final Map<String,Object> travel=fields("id",1L,"postalCode","10115","country","DE","globalMinutes",30,"localMinutes",null);
|
||||
@BeforeEach void setup(){
|
||||
when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
|
||||
when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());
|
||||
when(db.lockRows(anyString(),any(Object[].class))).thenAnswer(c->((String)c.getArgument(0)).startsWith("SELECT ph_id")?holidays:List.of(fields("hq_id",7)));
|
||||
when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.startsWith("SELECT ph_id"))return holidays;if(sql.startsWith("SELECT p.srvp_id"))return List.of(travel);return List.of();});
|
||||
when(db.update(anyString(),any(Object[].class))).thenReturn(1);
|
||||
}
|
||||
@Test void rightsAreCheckedBeforeAnyDatabaseAccess(){
|
||||
var denied=new LegacyUser(11,12,7,"test","0".repeat(70));
|
||||
assertThatThrownBy(()->service.holidays(denied,2026)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(()->service.saveTravelTime(denied,TravelMode.BWV,1,new TravelSave("x",20))).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoInteractions(db);
|
||||
}
|
||||
@Test void historicalRowsAndTimeWindowsSurviveAnEmptyFutureCalendar(){
|
||||
holidays.add(fields("ph_id",1,"ph_month",1,"ph_day",1,"ph_name","Neujahr","ph_time_from","10:00","ph_time_to","12:00"));
|
||||
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","Weihnachten","ph_time_from","00:00","ph_time_to","00:00"));
|
||||
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of()));
|
||||
verify(db).update("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?",2L,7L);
|
||||
verify(db,never()).update(eq("DELETE FROM publicholiday WHERE ph_id=? AND hq_id=?"),eq(1L),any());
|
||||
}
|
||||
@Test void staleCalendarCannotOverwriteAnotherEditorsChanges(){
|
||||
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("outdated",List.of()))).hasMessageContaining("409");
|
||||
verify(db,never()).update(anyString(),any(Object[].class));verify(db,never()).insert(anyString(),anyMap());
|
||||
}
|
||||
@Test void validatesDateAndRejectsModificationOfToday(){
|
||||
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave("x",List.of(new Holiday(2,29,"x"))))).hasMessageContaining("Feiertagsdatum");
|
||||
assertThatThrownBy(()->service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(10,12,"x"))))).hasMessageContaining("heute");
|
||||
verify(db,never()).insert(anyString(),anyMap());
|
||||
}
|
||||
@Test void futureNamesAreUpdatedWithoutReplacingTimeWindows(){
|
||||
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","alt","ph_time_from","10:00","ph_time_to","12:00"));
|
||||
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"neu"))));
|
||||
verify(db).update("UPDATE publicholiday SET ph_name=? WHERE ph_id=? AND hq_id=?","neu",2L,7L);
|
||||
verify(db,never()).insert(anyString(),anyMap());
|
||||
}
|
||||
@Test void multipleTimeWindowsRepresentOneDayAndSurviveAnUnchangedSave(){
|
||||
holidays.add(fields("ph_id",2,"ph_month",12,"ph_day",25,"ph_name","vormittags","ph_time_from","10:00","ph_time_to","12:00"));
|
||||
holidays.add(fields("ph_id",3,"ph_month",12,"ph_day",25,"ph_name","nachmittags","ph_time_from","14:00","ph_time_to","16:00"));
|
||||
assertThat((List<?>)service.holidays(user,2026).get("days")).hasSize(1);
|
||||
service.saveHolidays(user,2026,new HolidaySave(AdminSupport.revision(holidays),List.of(new Holiday(12,25,"vormittags"))));
|
||||
verify(db,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
@Test void classicCreatesOnlyThisHeadquartersOverride(){
|
||||
service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),45));
|
||||
verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,45);
|
||||
verify(db,never()).update(startsWith("UPDATE serviceplz SET"),any(Object[].class));
|
||||
}
|
||||
@Test void bwvIsOneConditionalWriteAndReportsConcurrentUpdates(){
|
||||
when(db.update(startsWith("UPDATE serviceplz SET"),any(Object[].class))).thenReturn(0);
|
||||
assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),45))).hasMessageContaining("409");
|
||||
verify(db).update("UPDATE serviceplz SET srvp_traveltime=? WHERE srvp_id=? AND srvp_valid=1 AND srvp_traveltime <=> ?",45,1L,30);
|
||||
verify(db,never()).insert(anyString(),anyMap());
|
||||
}
|
||||
@Test void zeroAndEmptyTravelTimeUseTheConfiguredHeadquartersDefault() {
|
||||
when(db.queryForList(startsWith("SELECT par_value FROM parameter"),eq(7L),eq("LATEST_TAKETIME_IN_MINUTES"))).thenReturn(List.of(fields("par_value","35")));
|
||||
service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.CLASSIC,null)),0));
|
||||
verify(db).update("INSERT INTO serviceplztraveltime (srvp_id,hq_id,srvpt_traveltime) VALUES (?,?,?)",1L,7L,35);
|
||||
service.saveTravelTime(user,TravelMode.BWV,1,new TravelSave(AdminSupport.revision(Arrays.asList(1L,TravelMode.BWV,30)),null));
|
||||
verify(db).update(startsWith("UPDATE serviceplz SET"),eq(35),eq(1L),eq(30));
|
||||
}
|
||||
@Test void missingDefaultNeverWritesAnInventedTravelTime() {
|
||||
assertThatThrownBy(()->service.saveTravelTime(user,TravelMode.CLASSIC,1,new TravelSave("",0))).hasMessageContaining("LATEST_TAKETIME_IN_MINUTES");
|
||||
verify(db,never()).update(anyString(),any(Object[].class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import java.util.*;import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
public final class ContactIntegrationAssertions {
|
||||
public static void verifyContactWrites(DatabaseService db) throws Exception {
|
||||
var scope=mock(CrmScope.class);when(scope.checked(any(),any())).thenReturn(List.of(7L));var service=new ContactService(db,scope);var user=AppointmentServiceTest.user(0,11);String schema=(String)service.schema(user,7).get("revision");
|
||||
try(var executor=Executors.newFixedThreadPool(2)){var calls=new ArrayList<Callable<Map<String,Object>>>();for(int i=0;i<2;i++){int index=i;calls.add(()->service.save(user,null,new ContactService.Save(7,null,schema,Map.of(1L,"Kontakt "+index,2L,"Notiz"))));}var result=executor.invokeAll(calls);assertThat(result.get(0).get().get("id")).isNotEqualTo(result.get(1).get().get("id"));}
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(4);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory",Integer.class)).isEqualTo(4);
|
||||
var detail=service.get(user,7,1);service.save(user,1L,new ContactService.Save(7,(String)detail.get("revision"),schema,Map.of(1L,"Geändert",2L,"Notiz")));assertThatThrownBy(()->service.save(user,1L,new ContactService.Save(7,(String)detail.get("revision"),schema,Map.of(1L,"Veraltet")))).hasMessageContaining("409");
|
||||
assertThatThrownBy(()->db.contactWriteTransaction(()->{db.update("DELETE FROM metafieldvalue WHERE mtfv_id=?",1);throw new IllegalStateException("rollback");})).hasMessage("rollback");assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=1",Integer.class)).isEqualTo(2);
|
||||
var updated=service.get(user,7,1);service.remove(user,7,1,(String)updated.get("revision"));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(2);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory",Integer.class)).isEqualTo(5);
|
||||
// Exercise the shared category-scoped editor against the same real InnoDB locks.
|
||||
db.writeTransaction(()->{db.update("INSERT INTO metafieldkey VALUES (1701,'Beginn','date_begin'),(1702,'Ende','date_end'),(1703,'Niederlassungen','checkboxes_hq')");db.update("INSERT INTO metafieldcategorykey VALUES (60,15,1701,0,1,1),(61,15,1702,0,2,1),(62,15,1703,0,3,0)");return null;});
|
||||
var forms=mock(FormAdministration.class);var messages=mock(MessageAdministration.class);var actor=AppointmentServiceTest.user(0,8);when(messages.headquarters(actor)).thenReturn(List.of(OrderEntryService.fields("id",7L,"label","B")));var evaluations=new EvaluationService(db,forms,messages);String evalSchema=(String)evaluations.schema(actor,7).get("revision");
|
||||
var saved=evaluations.save(actor,null,new MetafieldRecords.Save(7,null,evalSchema,Map.of(60L,"01.10.2026",61L,"31.10.2026",62L,"7")));long evalId=((Number)saved.get("id")).longValue();assertThat(evalId).isGreaterThan(2);var detailEval=evaluations.get(actor,7,evalId);assertThat((Map<?,?>)detailEval.get("values")).hasSize(3);
|
||||
evaluations.save(actor,evalId,new MetafieldRecords.Save(7,(String)detailEval.get("revision"),evalSchema,Map.of(60L,"02.10.2026")));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=?",Integer.class,evalId)).isEqualTo(3);assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvaluehistory WHERE mtfv_id=?",Integer.class,evalId)).isEqualTo(3);
|
||||
var updatedEval=evaluations.get(actor,7,evalId);evaluations.remove(actor,7,evalId,(String)updatedEval.get("revision"));assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue WHERE mtfv_id=?",Integer.class,evalId)).isZero();assertThat(db.queryForObject("SELECT COUNT(*) FROM metafieldvalue",Integer.class)).isEqualTo(2);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.util.*;import java.util.function.Supplier;import java.time.*;import java.math.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
class CrmServiceTest {
|
||||
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final CrmScope scope=new CrmScope(db);final ContactService contacts=new ContactService(db,scope);final SalesAnalytics sales=new SalesAnalytics(db,scope,catalog,"phoenix_log");final LegacyUser user=AppointmentServiceTest.user(0,11),master=AppointmentServiceTest.user(0,10,11);
|
||||
final List<Map<String,Object>> defs=List.of(fields("id",1L,"keyId",101,"label","Name","type","varchar","history",1),fields("id",2L,"keyId",104,"label","Geburtstag","type","birthdate","history",0),fields("id",3L,"keyId",300,"label","Kunde","type","co2cs","history",0));
|
||||
List<Map<String,Object>> values=List.of(fields("mtfck_id",1L,"mtfv_id",44L,"mtfv_value","Alt"));final List<String> queries=new ArrayList<>(),writes=new ArrayList<>();final List<List<Object>> arguments=new ArrayList<>();List<Map<String,Object>> targetRows=List.of();boolean huge=false,brokenSchema=false;
|
||||
@BeforeEach void setup(){when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(startsWith("SELECT COALESCE(MAX"),eq(Long.class))).thenReturn(99L);when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);queries.add(sql);arguments.add(Arrays.asList(c.getArguments()).subList(1,c.getArguments().length));if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));if(sql.startsWith("SELECT ck.mtfck_id"))return brokenSchema?List.of():defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return values;if(sql.startsWith("SELECT cs_id"))return List.of(fields("cs_id",9L));if(sql.startsWith("SELECT DISTINCT v.mtfv_id"))return huge?Collections.nCopies(501,fields("id",44L,"headquartersId",7L)):List.of();if(sql.startsWith("SELECT jb.hq_id"))return List.of(fields("id",7L,"jobs",2L,"amount",new BigDecimal("100.00")));if(sql.startsWith("SELECT l.usr_id"))return List.of(fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=jb|OLD=1|NEW=0"),fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=cs|OLD=|NEW=0"));if(sql.startsWith("SELECT cs.cs_id AS id"))return List.of(fields("id",9L,"amount",new BigDecimal("20.00")));if(sql.startsWith("SELECT par_id"))return targetRows;return List.of();});when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{writes.add(c.getArgument(0));return 1;});when(db.lockRows(startsWith("SELECT hq_id"),any(Object[].class))).thenReturn(List.of(fields("hq_id",7L)));when(db.lockRows(startsWith("SELECT par_id"),any(Object[].class))).thenAnswer(c->targetRows);when(catalog.parameter(anyLong(),anyString())).thenReturn("");}
|
||||
ContactService.Save save(String rev,Map<Long,String> v){return new ContactService.Save(7,rev,AdminSupport.revision(defs),v);}
|
||||
@Test void scopeCannotExpandToOtherTenantOrMissingSalesRight(){for(var u:List.of(AppointmentServiceTest.user(0),AppointmentServiceTest.user(11)))assertThatThrownBy(()->contacts.schema(u,7)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db);assertThatThrownBy(()->contacts.schema(user,8)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->contacts.schema(master,99)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);}
|
||||
@Test void creationUsesLockedGlobalIdAndLegacyHistory(){assertThat(contacts.save(user,null,save(null,Map.of(1L,"Neu")))).containsEntry("id",99L);verify(db).contactWriteTransaction(any());assertThat(writes).filteredOn(s->s.startsWith("INSERT INTO metafieldvalue ")).hasSize(3);verify(db).update(startsWith("INSERT INTO metafieldvaluehistory"),eq(1L),eq(99L),eq("Neu"),any(LocalDateTime.class));}
|
||||
@Test void staleValuesAndDefinitionChangesCannotDeleteExistingContact(){assertThatThrownBy(()->contacts.save(user,44L,save("stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThatThrownBy(()->contacts.save(user,44L,new ContactService.Save(7,"x","stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThat(writes).isEmpty();}
|
||||
@Test void contactsRejectInvalidDatesUnknownFieldsAndOversizeUtf8(){for(var vals:List.of(Map.of(2L,"31.02.2026"),Map.of(999L,"unbekannt"),Map.of(1L,"€".repeat(22000)),Map.of(1L," ")))assertThatThrownBy(()->contacts.save(user,null,save(null,vals))).hasMessageContaining("400");assertThat(writes).isEmpty();}
|
||||
@Test void updatesAndDeletesAreScopedToDefinitionIdsNotObjectIdAlone(){contacts.save(user,44L,save(AdminSupport.revision(values),Map.of(1L,"Neu")));contacts.remove(user,7,44,AdminSupport.revision(values));assertThat(writes).filteredOn(s->s.startsWith("DELETE")).hasSize(2).allMatch(s->s.contains("mtfv_id=? AND mtfck_id IN (?,?,?)"));}
|
||||
@Test void historyRequiresAnAccessibleCurrentContact(){values=List.of();assertThatThrownBy(()->contacts.history(user,7,44)).hasMessageContaining("404");assertThat(queries).noneMatch(s->s.contains("FROM metafieldvaluehistory"));}
|
||||
@Test void contactSearchBindsWildcardsAndCustomerScope(){contacts.list(user,new ContactService.Query(List.of(7L),"%_x'",false,"A",9L));assertThat(queries).anyMatch(s->s.contains("sv.mtfv_value LIKE ?")&&s.contains("cv.mtfv_value=?"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("%\\%\\_x'%","A%","9");huge=true;assertThatThrownBy(()->contacts.list(user,new ContactService.Query(List.of(7L),"",false,"",null))).hasMessageContaining("500 Kontakte");}
|
||||
@Test void statusCounterIncludesOnlyConversionsToCustomers(){var r=sales.switches(user,new SalesAnalytics.Query(LocalDate.of(2026,1,1),LocalDate.of(2026,10,13),List.of(7L)));assertThat((List<?>)r.get("conversions")).hasSize(1);assertThat(r.get("summary").toString()).contains("jb1=1");assertThat(queries).anyMatch(s->s.contains("l.hq_id IN (?) AND c.hq_id IN (?)"));assertThat(queries).anyMatch(s->s.contains("jb_status=2")&&s.contains("jb_storno IN (0,1,3)")&&s.contains("jb.hq_id=cs.hq_id"));}
|
||||
@Test void statusParserAndPriorYearMatchLegacyLeapHandling(){assertThat(SalesAnalytics.description("SRC=jb|OLD=2|NEW=0")).containsEntry("NEW","0");assertThat(SalesAnalytics.previousYear(LocalDate.of(2024,2,29))).isEqualTo(LocalDate.of(2023,2,28));assertThat(SalesAnalytics.previousYear(LocalDate.of(2025,2,28))).isEqualTo(LocalDate.of(2024,2,29));}
|
||||
@Test void motivationHandlesMissingTargetsAndEmptyBranchWithoutDividingByZero(){var r=sales.motivation(master,YearMonth.now().toString(),List.of(7L,8L));var rows=(List<Map<String,Object>>)r.get("items");assertThat(rows.getFirst()).containsEntry("actual",new BigDecimal("100.00")).containsEntry("percent",null);assertThat(rows.get(1)).containsEntry("actual",BigDecimal.ZERO).containsEntry("jobs",0L);assertThat(SalesAnalytics.percent(new BigDecimal("75"),new BigDecimal("100"))).isEqualByComparingTo("75");}
|
||||
@Test void exclusionsAreBoundAndRelatedCustomerSettingIsHonored(){when(catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS")).thenReturn("TEST-1, TEST-2'");sales.motivation(user,YearMonth.now().toString(),List.of(7L));assertThat(queries).anyMatch(s->s.contains("cs.cs_eid NOT IN (?,?)")&&!s.contains("TEST-2"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("TEST-1","TEST-2'");when(catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")).thenReturn(true);sales.switches(user,new SalesAnalytics.Query(LocalDate.now(),LocalDate.now(),List.of(7L)));assertThat(queries).anyMatch(s->s.contains("csc.csc_id=jb.csc_id_related"));}
|
||||
@Test void targetsRequireConfiguredPasswordAndLockOutRepeatedFailures(){assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"x"))).hasMessageContaining("kein Motivation");when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");for(int i=0;i<5;i++)assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"wrong"))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only"))).hasMessageContaining("fünf Minuten");assertThat(writes).isEmpty();}
|
||||
@Test void targetEditingPreservesHistoryAndChecksRevision(){when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");String m=YearMonth.now().toString();targetRows=List.of(fields("id",5L,"name","MOTIVATIONCOUNTER_"+m.replace('-','_'),"amount","100.00"));var t=new SalesAnalytics.Target(7,m,new BigDecimal("150.00"),"test-only",AdminSupport.revision(targetRows.getFirst()));assertThat(sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only")).getFirst()).containsEntry("month",m);sales.target(user,t);assertThat(writes).contains("UPDATE parameter SET par_value=? WHERE par_id=?");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,m,t.amount(),t.password(),"stale"))).hasMessageContaining("409");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,YearMonth.now().minusMonths(1).toString(),t.amount(),t.password(),t.revision()))).hasMessageContaining("vergangener");}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static de.stadtbote.votian.LegacyMapping.*;
|
||||
public class CustomerServicesIntegrationAssertions {
|
||||
public static List<String> setup(){return List.of(
|
||||
"DROP TABLE IF EXISTS customer,service,servicetype,customerservice,customerservicetype,parameter,genericdatacontainer",
|
||||
"CREATE TABLE customer(cs_id INT PRIMARY KEY,hq_id INT) ENGINE=InnoDB",
|
||||
"INSERT INTO customer VALUES(9,7),(10,8)",
|
||||
"CREATE TABLE service(srv_id INT PRIMARY KEY,hq_id INT,cs_id INT,srv_name VARCHAR(100),srv_mode INT) ENGINE=InnoDB",
|
||||
"INSERT INTO service VALUES(1,0,0,'PKW',1),(2,0,0,'Kombi',1),(3,8,0,'Fremd',1),(4,0,0,'Wartezeit',0)",
|
||||
"CREATE TABLE servicetype(srvt_id INT PRIMARY KEY,hq_id INT,cs_id INT,srvt_name VARCHAR(100),srvt_mode INT) ENGINE=InnoDB",
|
||||
"INSERT INTO servicetype VALUES(11,0,0,'Kilometer',1),(12,0,0,'Stopps',1)",
|
||||
"CREATE TABLE customerservice(cs_id INT,srv_id INT,cssrv_sort INT) ENGINE=MyISAM",
|
||||
"INSERT INTO customerservice VALUES(9,1,0),(10,3,0)",
|
||||
"CREATE TABLE customerservicetype(cs_id INT,srvt_id INT,cssrvt_sort INT) ENGINE=MyISAM",
|
||||
"INSERT INTO customerservicetype VALUES(9,11,0)",
|
||||
"CREATE TABLE parameter(hq_id INT,emp_id INT,par_key VARCHAR(100),par_value VARCHAR(100)) ENGINE=InnoDB",
|
||||
"CREATE TABLE genericdatacontainer(gdc_id INT PRIMARY KEY AUTO_INCREMENT,gdc_obj_type VARCHAR(10),gdc_obj_id INT,gdc_gen_fieldname VARCHAR(50),gdc_content MEDIUMTEXT,gdc_context TEXT) ENGINE=InnoDB"
|
||||
);}
|
||||
public static void verify(DatabaseService db)throws Exception{
|
||||
var u=new LegacyUser(11,12,7,"QA","1".repeat(70));var support=new BusinessSupport(db,new ObjectMapper());var service=new CustomerServices(db,support,new OrderEntryCatalog(db));var initial=service.view(u,9);
|
||||
assertThat((List<?>)initial.get("serviceOptions")).hasSize(3);
|
||||
assertThatThrownBy(()->service.view(u,10)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(()->service.view(new LegacyUser(11,12,7,"QA","110"+"1".repeat(67)),9)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
|
||||
assertThatThrownBy(()->service.save(u,9,new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(3L),List.of(11L)))).hasMessageContaining("nicht freigegeben");
|
||||
var input=new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(2L,1L,4L),List.of(12L,11L));
|
||||
var failing=spy(db);doAnswer(c->{c.callRealMethod();throw new org.springframework.dao.DataAccessResourceFailureException("lost MyISAM insert acknowledgement");}).when(failing).update(startsWith("INSERT INTO customerservicetype"),any(Object[].class));
|
||||
var interrupted=new CustomerServices(failing,new BusinessSupport(failing,new ObjectMapper()),new OrderEntryCatalog(failing));
|
||||
assertThat(interrupted.save(u,9,input)).containsEntry("state","PENDING");
|
||||
assertThat(db.queryForList("SELECT srvt_id FROM customerservicetype WHERE cs_id=9")).hasSize(1);
|
||||
assertThat(service.view(u,9).get("operations")).asList().hasSize(1);
|
||||
assertThat(service.resume(u,9,input.requestId())).containsEntry("state","DONE");assertThat(service.save(u,9,input)).containsEntry("state","DONE");
|
||||
assertThat(service.view(u,9)).containsEntry("services",List.of(2L,1L,4L)).containsEntry("types",List.of(12L,11L));
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM genericdatacontainer WHERE gdc_obj_type='ngaudit'",Integer.class)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT srv_id FROM customerservice WHERE cs_id=10",Long.class)).isEqualTo(3L);
|
||||
assertThatThrownBy(()->service.save(u,9,new CustomerServices.Save(UUID.randomUUID().toString(),text(initial,"revision"),List.of(1L),List.of(11L)))).hasMessageContaining("409");
|
||||
assertThat(CustomerServices.recoverable(List.of(new CustomerServices.Assignment(4,0)),List.of(new CustomerServices.Assignment(1,0)),List.of(2L,1L))).isFalse();
|
||||
support.json.writeValue(java.nio.file.Path.of("target/customer-services-ui-fixture.json").toFile(),service.view(u,9));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import static de.stadtbote.votian.OrderEntryService.fields;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
class EvaluationServiceTest {
|
||||
final DatabaseService db=mock(DatabaseService.class);final FormAdministration forms=mock(FormAdministration.class);final MessageAdministration messages=mock(MessageAdministration.class);final EvaluationService service=new EvaluationService(db,forms,messages);final LegacyUser user=AppointmentServiceTest.user(0,1,8);
|
||||
final List<Map<String,Object>> defs=List.of(fields("id",60L,"keyId",1701L,"type","date_begin","label","Beginn","history",1),fields("id",61L,"keyId",1702L,"type","date_end","label","Ende","history",0),fields("id",62L,"keyId",1703L,"type","checkboxes_hq","label","Niederlassungen","history",0));
|
||||
final List<Map<String,Object>> raw=List.of(fields("mtfck_id",60L,"mtfv_id",12L,"mtfv_value","01.10.2026"),fields("mtfck_id",61L,"mtfv_id",12L,"mtfv_value","31.10.2026"),fields("mtfck_id",62L,"mtfv_id",12L,"mtfv_value","7"));
|
||||
@BeforeEach void setup(){when(messages.headquarters(user)).thenReturn(List.of(fields("id",7L,"label","B")));when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);if(sql.equals(MetafieldRecords.definitionsSql(15)))return defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return raw;return List.of();});when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(13L);}
|
||||
MetafieldRecords.Save draft(Map<Long,String> values){return new MetafieldRecords.Save(7,AdminSupport.revision(raw),AdminSupport.revision(defs),values);}
|
||||
@Test void evaluationRequiresBothCustomerAndStatisticsRights(){assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(0),7)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.schema(AppointmentServiceTest.user(8),7)).isInstanceOf(AccessDeniedException.class);}
|
||||
@Test void globalCategoryHasIndependentBindings(){assertThat(service.schema(user,7)).containsEntry("headquarters",0L);verify(forms).fieldHeadquarters(user,15);verify(db).queryForList(MetafieldRecords.definitionsSql(15),0L);}
|
||||
@Test void datesRemainPhpCompatibleAndValuesAreWrittenUnderMetafieldLock(){service.save(user,12L,draft(Map.of(60L,"02.10.2026")));verify(db).contactWriteTransaction(any());verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",60L,12L,"02.10.2026");verify(db).update("INSERT INTO metafieldvalue (mtfck_id,mtfv_id,mtfv_value) VALUES (?,?,?)",62L,12L,"7");}
|
||||
@Test void invalidDatesAndReverseRangeAreRejectedBeforeWriting(){assertThatThrownBy(()->service.save(user,12L,draft(Map.of(60L,"31.02.2026")))).hasMessageContaining("Datum");assertThatThrownBy(()->service.save(user,12L,draft(Map.of(60L,"01.11.2026")))).hasMessageContaining("Enddatum");verify(db,never()).contactWriteTransaction(any());}
|
||||
@Test void branchSelectionCannotExpandScope(){assertThatThrownBy(()->service.save(user,12L,draft(Map.of(62L,"7,8")))).hasMessageContaining("freigegeben");assertThatThrownBy(()->service.lookup(user,8,"customer","")).isInstanceOf(AccessDeniedException.class);verify(db,never()).contactWriteTransaction(any());}
|
||||
@Test void foreignOrUnknownLinkedObjectsCannotBeSaved(){assertThatThrownBy(()->service.validateValue(user,7,fields("type","button:cs:cs_eid"),"OTHER")).hasMessageContaining("404");assertThatThrownBy(()->service.validateValue(user,7,fields("type","select:crvh:crvh_sid"),"OTHER")).hasMessageContaining("nicht gefunden");}
|
||||
@Test void lookupUsesSessionScopeAndEscapesWildcards(){service.lookup(user,7,"customer","10%_");verify(db).queryForList(contains("WHERE cs.hq_id=?"),eq(7L),eq("%10\\%\\_%"),eq("%10\\%\\_%"));}
|
||||
@Test void deletionNeverTouchesOtherCategoriesOrHistory(){service.remove(user,7,12,AdminSupport.revision(raw));verify(db).update("DELETE FROM metafieldvalue WHERE mtfv_id=? AND mtfck_id IN (?,?,?)",12L,60L,61L,62L);verify(db,never()).update(startsWith("DELETE FROM metafieldvaluehistory"),any(Object[].class));}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.*;
|
||||
class FilterAdministrationTest {
|
||||
final DatabaseService db=mock(DatabaseService.class);final FilterAdministration service=new FilterAdministration(db,new ObjectMapper(),"phoenix_log");final LegacyUser admin=AppointmentServiceTest.user(0,32);
|
||||
@Test void readAndWriteRightsAreIndependent(){assertThatThrownBy(()->service.options(AppointmentServiceTest.user(32))).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.save(AppointmentServiceTest.user(0),null,null)).isInstanceOf(AccessDeniedException.class);assertThatThrownBy(()->service.resume(AppointmentServiceTest.user(0),UUID.randomUUID().toString())).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(db);}
|
||||
@Test void invalidTokenAndCodesNeverReachDatabase(){assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save("wrong","",0,"OK","Test",1,0,""))).hasMessageContaining("Vorgangskennung");assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save(UUID.randomUUID().toString(),"",0,"A,B","Test",1,0,""))).hasMessageContaining("Kürzel");verifyNoInteractions(db);}
|
||||
@Test void invalidClassificationAndUtf8OverflowAreRejected(){assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save(UUID.randomUUID().toString(),"",2,"OK","Test",1,0,""))).hasMessageContaining("Filterart");assertThatThrownBy(()->service.save(admin,null,new FilterAdministration.Save(UUID.randomUUID().toString(),"",0,"OK","Test",1,0,"😀".repeat(8000)))).hasMessageContaining("Langtext");verifyNoInteractions(db);}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.stadtbote.votian;
|
||||
import de.stadtbote.votian.database.DatabaseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
public class FilterIntegrationAssertions {
|
||||
static FilterAdministration.Save draft(String token,String revision,String code,String name,int sort,String text){return new FilterAdministration.Save(token,revision,0,code,name,1,sort,text);}
|
||||
public static void verify(DatabaseService db)throws Exception{
|
||||
var json=new ObjectMapper();var user=AppointmentServiceTest.user(0,32);var service=new FilterAdministration(db,json,"crm_qa");String token=UUID.randomUUID().toString();var save=draft(token,"","NGT","Neuer Testfilter",8,"Kurier's Hinweis");
|
||||
assertThat(service.save(user,null,save)).containsEntry("state","DONE");assertThat(service.save(user,null,save)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter",Integer.class)).isEqualTo(1);assertThat(db.queryForObject("SELECT COUNT(*) FROM log",Integer.class)).isEqualTo(1);
|
||||
var row=service.rows().getFirst();long id=LegacyMapping.id(row,"crf_id");assertThat(LegacyMapping.text(row,"crf_longtext")).isEqualTo("Kurier's Hinweis");
|
||||
assertThat(service.save(user,id,draft(UUID.randomUUID().toString(),AdminSupport.revision(row),"NGT","Neuer Testfilter",17,"Neuer Text"))).containsEntry("state","DONE");assertThat(service.row(id)).containsEntry("crf_sort",17).containsEntry("crf_longtext","Neuer Text");
|
||||
assertThatThrownBy(()->service.save(user,id,draft(UUID.randomUUID().toString(),AdminSupport.revision(row),"NGT","Neuer Testfilter",18,"Veraltet"))).hasMessageContaining("409");
|
||||
var failing=spy(db);doThrow(new org.springframework.dao.DataAccessResourceFailureException("simulated audit failure")).when(failing).queryForList(startsWith("SELECT log_id"),any(Object[].class));var interrupted=new FilterAdministration(failing,json,"crm_qa");
|
||||
String retry=UUID.randomUUID().toString();assertThat(interrupted.save(user,null,draft(retry,"","REP","Wiederaufnehmen",1,"Text"))).containsEntry("state","PENDING");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter WHERE crf_short='REP'",Integer.class)).isEqualTo(1);assertThat(service.resume(user,retry)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter WHERE crf_short='REP'",Integer.class)).isEqualTo(1);
|
||||
db.writeTransaction(()->{db.update("INSERT INTO customer VALUES(1,1,',NGT,KEEP,','2020-01-01'),(2,2,',NGT,','2020-01-01')");db.update("INSERT INTO courier VALUES(1,1,',NGT,','2020-01-01'),(2,2,',NGT,','2020-01-01')");db.update("INSERT INTO couriervehicle VALUES(1,',NGT,KEEP,','2020-01-01')");return null;});
|
||||
String deletion=UUID.randomUUID().toString();assertThat(interrupted.remove(user,id,new FilterAdministration.Delete(deletion,AdminSupport.revision(service.row(id))))).containsEntry("state","PENDING");assertThat(db.queryForObject("SELECT COUNT(*) FROM courierfilter WHERE crf_id=?",Integer.class,id)).isZero();assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=1",String.class)).isEqualTo(",NGT,KEEP,");
|
||||
assertThat(service.resume(user,deletion)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=1",String.class)).isEqualTo(",KEEP,");assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=2",String.class)).isEqualTo(",NGT,");assertThat(db.queryForObject("SELECT cr_filter FROM courier WHERE cr_id=1",String.class)).isEmpty();assertThat(db.queryForObject("SELECT crvh_filter FROM couriervehicle WHERE crvh_id=1",String.class)).isEqualTo(",KEEP,");assertThat(db.queryForObject("SELECT YEAR(cs_modify) FROM customer WHERE cs_id=1",Integer.class)).isEqualTo(2020);
|
||||
assertThat(service.resume(user,deletion)).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT par_value FROM parameter WHERE par_key='FILTER_TO_BE_DELETED'",String.class)).isEqualTo(",NGT,");assertThatThrownBy(()->service.save(user,null,draft(UUID.randomUUID().toString(),"","NGT","Neuanlage gesperrt",1,""))).hasMessageContaining("gesperrt");
|
||||
db.writeTransaction(()->{db.update("INSERT INTO courierfilter (crf_type,crf_short,crf_text,crf_status,vht_id,crf_sort,crf_longtext) VALUES(0,'KF','Kühlfahrzeug',1,0,1,''),(0,'KF','Koffer',1,0,2,'')");db.update("UPDATE customer SET cs_filter=',KF,' WHERE cs_id=1");return null;});
|
||||
var duplicate=db.queryForList("SELECT "+FilterAdministration.COLUMNS+" FROM courierfilter WHERE crf_short='KF' ORDER BY crf_id").getFirst();assertThat(service.remove(user,LegacyMapping.id(duplicate,"crf_id"),new FilterAdministration.Delete(UUID.randomUUID().toString(),AdminSupport.revision(duplicate)))).containsEntry("state","DONE");assertThat(db.queryForObject("SELECT cs_filter FROM customer WHERE cs_id=1",String.class)).isEqualTo(",KF,");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user