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 comparisons,List headquarters,DateBasis dateBasis,State state,Amount amount,Payment payment,Period period,List 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 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 headquarters(LegacyUser user,List 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 calculate(LegacyUser user,Query query) { user.require(8);validate(query); return db.readTransaction(()->{ List 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();intervals.add(query.interval());if(query.comparisons()!=null)intervals.addAll(query.comparisons()); var results=new ArrayList>(); 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 hqs){var args=new ArrayList(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 headquarters,boolean total,String logTable) { String at="j."+q.dateBasis().column; var args=new ArrayList(headquarters); String where="j.hq_id IN ("+String.join(",",Collections.nCopies(headquarters.size(),"?"))+") AND "+at+">=? AND "+at+"'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();columns.add(total?"'Gesamt' AS period":period+" AS period"); var groups=new ArrayList();groups.add("period"); if(!total&&q.splits()!=null)for(int i=0;i"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()); } }