Files
votianng/backend/src/main/java/de/stadtbote/votian/CustomerServices.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

53 lines
8.7 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.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()&&current.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());
}
}