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,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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user