Files
votianng/backend/src/main/java/de/stadtbote/votian/CustomerCourierAssignment.java
T
Sven 781a2b82dc Erweitere VoTiAn NG um Fachfunktionen und verbessere die Listenoberfläche
Ergänze Auftragsbearbeitung, Disposition, Stammdaten, Kunden- und
Transporteurportale, Lagerverwaltung, Abrechnung, Exporte und Statistik.
Füge Kontoeinstellungen, Mitteilungen, Automatikdienste und Betriebsstatus
sowie die zugehörigen Tests und PHP-Abgleichsdokumentation hinzu.

Vergrößere die persönlichen Auftragslisten auf einheitliche Höhen und
richte Statusanzeigen sowie die Ranking-Vorschau-Steuerelemente bündig aus.

Validierung: 44 Frontendtests und Produktionsbuild erfolgreich.
Backend funktional unverändert gegenüber dem vollständigen Lauf mit 1.624
erfolgreichen und drei übersprungenen Tests. Layout im Browser und lokale
Bereitstellung geprüft.
2026-10-20 11:19:43 +02:00

84 lines
11 KiB
Java

package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
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;
/** customer/jb_list.php: a customer may dispatch only to its own favoured couriers. */
@RestController
@RequestMapping("/api/customer-portal/orders/{job}/courier-assignment")
class CustomerCourierAssignment {
record Selection(long vehicle,long courier,boolean resetPreviousAvailableTime){}
record Command(String requestId,String revision,Selection selection,boolean confirmed){}
record State(long customer,String accessRevision,Map<String,Object> job,List<Map<String,Object>> stops,List<Map<String,Object>> vehicles,List<String> blockers){}
record Prepared(State state,Selection selection,Map<String,Object> vehicle,boolean loseRanking,String rankingAfter,List<String> warnings,String revision){}
final DatabaseService db;final BusinessSupport support;final OrderEntryCatalog catalog;
CustomerCourierAssignment(DatabaseService db,BusinessSupport support){this.db=db;this.support=support;catalog=new OrderEntryCatalog(db);}
@GetMapping Object get(Authentication a,@PathVariable long job){return options(ApiController.user(a),job);}
@PostMapping("/preview") Object preview(Authentication a,@PathVariable long job,@RequestBody Selection s){return preview(ApiController.user(a),job,s);}
@PostMapping Object post(Authentication a,@PathVariable long job,@RequestBody Command c){return save(ApiController.user(a),job,c);}
State state(LegacyUser u,long job){
var context=CustomerPortalScope.context(db,u);context.require(4);long customer=id(context.account(),"cs_id");
var own=CustomerPortalScope.customer(context,customer);var args=new ArrayList<Object>(new TreeSet<>(own.allowed()));args.add(job);args.add(u.headquartersId());
var portal=new CustomerPortal(db);String related=catalog.parameter(0,"GLOBAL_USE_RELATED_CUSTOMER"),enabled=catalog.parameter(0,"CUSTOMER_MASK_JOBLIST_DISPO_FAV_ENABLED_"+customer);
if(!enabled.equals("1")||related.equals("1"))throw CustomerPortalScope.denied();
var rows=db.queryForList("SELECT jb.* FROM job jb WHERE ("+portal.payer(u)+") IN ("+CourierPortal.placeholders(own.allowed().size())+") AND jb.jb_id=? AND jb.hq_id=?",args.toArray());
if(rows.size()!=1||id(rows.getFirst(),"jb_id_parent")>0)throw CustomerPortalScope.denied();var j=rows.getFirst();var blockers=new ArrayList<String>();
if(!Set.of(0L,1L,8L,9L).contains(id(j,"jb_status"))||id(j,"jb_storno")!=0)blockers.add("Nur offene, nicht stornierte Aufträge können zugewiesen werden.");
if(OrderOperations.exported(j)||!db.queryForList("SELECT jb_id FROM invoice WHERE jb_id=?",job).isEmpty()||!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())blockers.add("Der Auftrag ist exportiert oder einer Rechnung zugeordnet.");
if(!db.queryForList("SELECT jb_id FROM job WHERE jb_id_copy=? AND jb_storno IN (3,4)",job).isEmpty())blockers.add("Für den Auftrag besteht bereits eine Gegenbuchung.");
var lock=dateTime(j,"jb_locktime");if(lock!=null&&lock.isAfter(db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().minusMinutes(5)))blockers.add("Der Auftrag wird gerade in der alten Anwendung bearbeitet.");
if(id(j,"hq_id_dispo")>0&&id(j,"hq_id_dispo")!=u.headquartersId())blockers.add("Die Zuweisung erfolgt durch eine andere Niederlassung.");
var stops=db.queryForList("SELECT t.tr_id,t.tr_sort,stopaddr.ad_zipcode FROM tour t LEFT JOIN address stopaddr ON stopaddr.ad_id=t.ad_id WHERE t.jb_id=? ORDER BY t.tr_sort,t.tr_id",job);
if(stops.isEmpty()||text(stops.getFirst(),"ad_zipcode").isBlank())blockers.add("Die Startadresse ist unvollständig.");
boolean multiple=AssignmentVehicles.multiple(catalog,u);
String sql="SELECT cv.crvh_id,cv.crvh_sid,cv.vht_id,cr.cr_sid,cr.cr_available,cr.cr_occupied,cr.cr_locationzipcode,cr.cr_availabletime,"+AssignmentVehicles.IDENTITY+" "+AssignmentVehicles.from(false,multiple)+" AND cr.cr_sid=cv.crvh_sid AND EXISTS (SELECT 1 FROM customercourier fav WHERE fav.cs_id=? AND fav.cr_id=cr.cr_id AND fav.cscr_relation=1) AND NOT EXISTS (SELECT 1 FROM customercourier blocked WHERE blocked.cs_id=? AND blocked.cr_id=cr.cr_id AND blocked.cscr_relation<>1) ORDER BY cv.crvh_sid,cv.crvh_id LIMIT 1001";
var vehicles=AssignmentVehicles.identities(db.queryForList(sql,u.headquartersId(),customer,customer)).stream().filter(v->!AssignmentVehicles.ambiguous(v,multiple)&&id(v,"logged_drivers")==1).toList();
if(vehicles.size()>1000)throw bad("Zu viele bevorzugte Fahrzeuge. Bitte die Zuordnungen prüfen.");
String access=revision(List.of(customer,own.allowed(),context.rights(),enabled,related,multiple));
return new State(customer,access,j,stops,vehicles,List.copyOf(blockers));
}
Map<String,Object> options(LegacyUser u,long job){return db.readTransaction(()->{var s=state(u,job);return fields("id",job,"current",text(s.job(),"cr_sid"),"canResetPrevious",id(s.job(),"cr_id")>0,"blockers",s.blockers(),"vehicles",s.vehicles().stream().map(v->fields("id",id(v,"crvh_id"),"courier",id(v,"cr_id"),"sid",text(v,"crvh_sid"),"available",id(v,"cr_available")==1,"occupied",id(v,"cr_occupied")!=0)).toList());});}
Prepared prepare(LegacyUser u,long job,Selection selection){
if(selection==null||selection.vehicle()<=0||selection.courier()<=0)throw bad("Bitte einen bevorzugten Kurier wählen.");
var s=state(u,job);if(!s.blockers().isEmpty())throw bad(String.join(" · ",s.blockers()));
var matches=s.vehicles().stream().filter(v->id(v,"crvh_id")==selection.vehicle()&&id(v,"cr_id")==selection.courier()).toList();
if(matches.size()!=1)throw CustomerPortalScope.denied();var v=matches.getFirst();var j=s.job();
if(id(j,"cr_id_order")==selection.courier()||id(j,"cr_id")==selection.courier())throw bad("Dieser Kurier ist dem Auftrag bereits zugewiesen.");
if(selection.resetPreviousAvailableTime()&&(id(j,"cr_id")<=0||db.queryForList("SELECT cr_id FROM courier WHERE cr_id=? AND hq_id=?",id(j,"cr_id"),u.headquartersId()).size()!=1))throw bad("Es gibt keinen bisherigen Kurier mit zurücksetzbarer Freimeldezeit.");
var warnings=new ArrayList<String>();if(id(v,"cr_available")!=1)warnings.add("Der Kurier ist nicht als verfügbar gemeldet.");if(id(v,"cr_occupied")!=0)warnings.add("Der Kurier ist bereits als belegt gemeldet.");if(id(v,"vht_id")!=id(j,"vht_id"))warnings.add("Die Fahrzeugklasse weicht vom Auftrag ab.");
var evidence=new ArrayList<Object>();evidence.add(s);evidence.add(selection);String start=text(s.stops().getFirst(),"ad_zipcode");long hq=id(j,"hq_id_dispo")>0?id(j,"hq_id_dispo"):u.headquartersId();
var lead=db.queryForList("SELECT p.srvp_id,lt.srvpt_traveltime FROM serviceplz p JOIN serviceplztraveltime lt ON lt.srvp_id=p.srvp_id WHERE p.srvp_plz=? AND lt.hq_id=? ORDER BY p.srvp_id",start,hq);evidence.add(lead);
if(lead.size()>1)throw bad("Die Anfahrtszeit für den Rankingverlust ist mehrdeutig.");
String after="";boolean deferred=false;var time=dateTime(j,"jb_ordertime");
if(time!=null&&lead.size()==1&&lead.getFirst().get("srvpt_traveltime")!=null){var threshold=time.minusMinutes(Math.max(30,id(lead.getFirst(),"srvpt_traveltime")));after=threshold.toString();deferred=!db.queryForObject("SELECT NOW()",java.sql.Timestamp.class).toLocalDateTime().isAfter(threshold);}
boolean lose=!deferred&&new FleetRanking(db,catalog).area(u,text(v,"cr_locationzipcode"),start,evidence).contains();
if(deferred)warnings.add("Künftiger Auftrag: Rankingverlust wird erst nach "+after.replace('T',' ')+" geprüft.");
evidence.add(after);evidence.add(deferred);evidence.add(lose);return new Prepared(s,selection,v,lose,after,List.copyOf(warnings),revision(evidence));
}
Map<String,Object> preview(LegacyUser u,long job,Selection selection){return db.readTransaction(()->{var p=prepare(u,job,selection);return fields("id",job,"revision",p.revision(),"sid",text(p.vehicle(),"crvh_sid"),"previous",text(p.state().job(),"cr_sid"),"loseRanking",p.loseRanking(),"rankingAfter",p.rankingAfter(),"warnings",p.warnings(),"resetPreviousAvailableTime",selection.resetPreviousAvailableTime());});}
Map<String,Object> save(LegacyUser u,long job,Command c){
if(c==null||!c.confirmed()||c.selection()==null||c.revision()==null||!c.revision().matches("[a-f0-9]{64}"))throw bad("Bitte die Zuweisung zuerst prüfen und bestätigen.");BusinessSupport.token(c.requestId());
support.transactional("job","courier","autoranking","genericdatacontainer","phoenix_pda.commandexec");
return db.customerAssignmentWriteTransaction(()->{
state(u,job);Object intent=List.of("CUSTOMER_ASSIGN",job,c);Long old=support.receipt(u,c.requestId(),intent);if(old!=null)return fields("id",job,"replayed",true);
var p=prepare(u,job,c.selection());if(!p.revision().equals(c.revision()))throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"Auftrag, Fahrer oder Freigabe wurden geändert. Bitte erneut prüfen.");
var j=p.state().job();long next=c.selection().courier();var now=LocalDateTime.now().withNano(0);
for(long previous:new TreeSet<>(List.of(id(j,"cr_id"),id(j,"cr_id_order"))))if(previous>0&&previous!=next)db.insert("`phoenix_pda`.commandexec",fields("cr_id",previous,"hq_id",u.headquartersId(),"cmd_command","4","cmde_mode","1","cmde_param",Long.toString(job),"cmde_exec_time",now.plusSeconds(30),"cmde_exec_timelimit",now.plusHours(36)));
if(c.selection().resetPreviousAvailableTime())db.update("UPDATE courier SET cr_availabletime=? WHERE cr_id=? AND hq_id=?",now,id(j,"cr_id"),u.headquartersId());
var ranks=db.queryForList("SELECT cr_id FROM autoranking WHERE jb_id=? AND cr_id=?",job,next);if(ranks.size()>1)throw bad("Die bisherige Kurierzuweisung ist mehrdeutig.");
if(ranks.isEmpty())BusinessSupport.insertWithoutKey(db,"autoranking",fields("jb_id",job,"cr_id",next,"ar_challenge",0,"ar_lastassigntime",now,"ar_looseranking",p.loseRanking()?1:0,"ar_locating",0));
else db.update("UPDATE autoranking SET ar_challenge=0,ar_lastassigntime=?,ar_looseranking=?,ar_locating=0 WHERE jb_id=? AND cr_id=?",now,p.loseRanking()?1:0,job,next);
var changes=fields("cr_id",0,"cr_id_order",next,"cr_sid",text(p.vehicle(),"crvh_sid"),"jb_status",0,"jb_globaljob",0,"jb_autoranking",0,"jb_taketime",null,"vht_id_real",id(p.vehicle(),"vht_id"),"jb_version",id(j,"jb_version")+1);BusinessSupport.update(db,"job","jb_id",job,changes);
var assigned=new LinkedHashMap<>(j);assigned.putAll(changes);new OrderSurvey(db,catalog).apply(u,assigned,c.requestId(),support);
support.audit(u,job,p.state().customer(),next,7,"Kundenzuweisung · LOST_RANKING="+(p.loseRanking()?"YES":"NO"),c.requestId());support.receipt(u,c.requestId(),intent,job);return fields("id",job,"replayed",false);
});
}
}