Files
votianng/backend/src/main/java/de/stadtbote/votian/StatisticsService.java
T
Sven 363bc27c26 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.
2026-10-13 20:31:31 +02:00

126 lines
13 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}