package de.stadtbote.votian; import de.stadtbote.votian.database.DatabaseService; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.time.*; import java.time.temporal.ChronoUnit; import java.util.*; import static de.stadtbote.votian.AdminSupport.*; import static de.stadtbote.votian.LegacyMapping.*; import static de.stadtbote.votian.OrderEntryService.fields; /** Port of groupware/appointment.php. Notifications use the separate preview/send workflow. */ @Service public class AppointmentService { static final String TABLE="`phoenix_group`.appointment"; static final List COLUMNS=List.of("ap_id","hq_id","usr_id","ap_type","ap_category_1","ap_category_2","ap_category_3","ap_category_4","ap_participants","ap_text","ap_execdate","ap_enddate","ap_confirmed","ap_modifytime","cs_id","csc_id","pt_id"); private final DatabaseService db; private final OrderEntryCatalog catalog; public AppointmentService(DatabaseService db,OrderEntryCatalog catalog) { this.db=db;this.catalog=catalog; } enum Visibility { OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC,INVITED,OTHER_PUBLIC } record Query(LocalDate from,LocalDate to,List headquarters,Long personId,Long customerId,Integer status,Integer kind,Integer monitoring,List visibility,String search) {} record Save(String revision,LocalDateTime startsAt,LocalDateTime endsAt,String text,int status,int visibility,int kind,int monitoring,Long customerId,List participants) {} record Resubmission(String revision,LocalDateTime startsAt,LocalDateTime endsAt) {} record Sql(String where,Object[] args) {} private void access(LegacyUser user) { user.require(0);user.require(11); } private static String marks(int size) { return String.join(",",Collections.nCopies(size,"?")); } private static String participant(long userId) { return "%,"+userId+",%"; } private List> headquarters(LegacyUser user) { return 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()}); } private List scope(LegacyUser user,List requested) { if(requested==null||requested.isEmpty()||requested.size()>100||requested.stream().anyMatch(Objects::isNull))throw bad("Bitte mindestens eine Niederlassung wählen."); var ids=requested.stream().distinct().sorted().toList(); if(ids.size()==1&&ids.getFirst()==user.headquartersId())return ids; user.require(10); var allowed=headquarters(user).stream().map(r->id(r,"id")).toList(); if(!allowed.containsAll(ids))throw new AccessDeniedException("Niederlassung ist für diesen Mandanten nicht freigegeben."); return ids; } private List allowedHeadquarters(LegacyUser user) { var ids=headquarters(user).stream().map(r->id(r,"id")).toList(); if(ids.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben.");return ids; } private List> employees(List hqs) { var rows=db.queryForList("SELECT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label,u.hq_id AS headquartersId,h.hq_mnemonic AS headquarters,e.emp_group AS groups FROM user u JOIN employee e ON e.usr_id=u.usr_id JOIN headquarters h ON h.hq_id=u.hq_id WHERE u.usr_type=1 AND u.hq_id IN ("+marks(hqs.size())+") ORDER BY u.usr_name,u.usr_firstname,u.usr_id LIMIT 5001",hqs.toArray()); if(rows.size()>5000)throw bad("Zu viele Mitarbeiter. Bitte die Niederlassungsauswahl eingrenzen."); return rows; } private Map>> categories() { var result=new LinkedHashMap>>(); var labels=new HashMap(); for(var p:db.queryForList("SELECT par_key,par_value FROM parameter WHERE emp_id=0 AND hq_id=0 AND par_key LIKE 'MASK_AP_CAT_%'"))labels.put(text(p,"par_key"),text(p,"par_value")); for(int category=1;category<=4;category++) { var options=new ArrayList>(); for(var r:db.queryForList("SELECT mt_sort AS id,MIN(mt_value) AS label FROM metatype WHERE mt_type=? GROUP BY mt_sort ORDER BY mt_sort","appointment_category_"+category)) { long id=id(r,"id");String override=labels.get("MASK_AP_CAT_"+category+"_"+id); options.add(fields("id",id,"label",override==null||override.isBlank()?text(r,"label"):override)); } if(category==4&&options.stream().noneMatch(o->id(o,"id")==2))options.add(fields("id",2L,"label",labels.getOrDefault("MASK_AP_CAT_4_9","Erledigt"))); result.put("category"+category,options); } return result; } public Map options(LegacyUser user) { access(user);var hqs=headquarters(user);if(hqs.isEmpty())throw new AccessDeniedException("Keine Niederlassung freigegeben."); var people=employees(hqs.stream().map(r->id(r,"id")).toList()); var groups=db.queryForList("SELECT grp_id AS id,grp_name AS label FROM groups WHERE md_id=? ORDER BY grp_name",mandator(db,user)); var groupViews=new ArrayList>(); for(var g:groups) { var members=people.stream().filter(p->GroupAdministration.memberships(text(p,"groups")).contains(id(g,"id"))).map(p->id(p,"id")).toList(); if(!members.isEmpty())groupViews.add(fields("id",id(g,"id"),"label",text(g,"label"),"members",members)); } return fields("headquarters",hqs,"currentHeadquarters",user.headquartersId(),"currentUser",user.userId(),"now",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0),"categories",categories(),"people",people,"groups",groupViews, "defaultMonitoring",catalog.enabled(0,"MASK_AP_CATEGORY_4_PRESET")?1:0,"miscType",catalog.parameter(0,"MASK_AP_CATEGORY_3_MISC_ITEM"),"canReadReports",user.hasRight(16),"canStatistics",user.hasRight(8),"showCustomerReports",catalog.enabled(0,"MASK_AP_BTN_CS_REPORT"),"canCreateCustomer",user.hasRight(0)&&catalog.enabled(0,"MASK_AP_BTN_CS_NEW")); } public List> customers(LegacyUser user,String q) { access(user);if(q==null||q.length()>100)throw bad("Suchtext zu lang.");var hqs=allowedHeadquarters(user); var args=new ArrayList(hqs);args.add("%"+q.strip()+"%"); return db.queryForList("SELECT c.cs_id AS id,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer c LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE c.hq_id IN ("+marks(hqs.size())+") AND CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) LIKE ? ORDER BY label,id LIMIT 50",args.toArray()); } public Map customerContext(LegacyUser user,long customerId) { access(user);return db.readTransaction(()->{ var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(customerId));args.addAll(hqs); var row=one(db.queryForList("SELECT c.cs_id AS id,c.cs_eid AS number,c.hq_id AS headquartersId,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS label FROM customer c LEFT JOIN company co ON co.cmp_id=c.cmp_id WHERE c.cs_id=? AND c.hq_id IN ("+marks(hqs.size())+")",args.toArray())); boolean own=id(row,"headquartersId")==user.headquartersId(); return fields("id",id(row,"id"),"number",text(row,"number"),"headquartersId",id(row,"headquartersId"),"label",text(row,"label"),"canOpenMaster",own,"canStatistics",user.hasRight(8),"masterUnavailableReason",own?"":"Der Kundenstamm wird in der Niederlassung des Kunden gepflegt."); }); } private void customer(LegacyUser user,long customerId) { var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(customerId));args.addAll(hqs); one(db.queryForList("SELECT cs_id FROM customer WHERE cs_id=? AND hq_id IN ("+marks(hqs.size())+")",args.toArray())); } static void validate(Query q,boolean statistics) { if(q==null)throw bad("Terminfilter fehlen.");range(q.from(),q.to()); if(q.from().getYear()<1000||q.to().getYear()>9998||!statistics&&ChronoUnit.DAYS.between(q.from(),q.to())>365)throw bad("Kalenderabfragen dürfen höchstens ein Jahr umfassen."); if(q.visibility()==null||q.visibility().isEmpty()||q.visibility().stream().anyMatch(Objects::isNull)||q.visibility().size()>5)throw bad("Bitte mindestens eine Sichtbarkeitsgruppe wählen."); if(q.search()==null||q.search().length()>200)throw bad("Suchtext darf höchstens 200 Zeichen enthalten."); for(Long id:Arrays.asList(q.personId(),q.customerId()))if(id!=null&&id<=0)throw bad("Ungültiger Personen-/Kundenfilter."); for(Integer code:Arrays.asList(q.status(),q.kind(),q.monitoring()))if(code!=null&&(code<0||code>127))throw bad("Ungültiger Kategoriefilter."); } /** Visibility is authorization, not merely a UI search option. No master bypass for private appointments. */ private static String visible(LegacyUser user,List args) { args.add(user.userId());args.add(participant(user.userId())); return "(a.usr_id=? OR a.ap_category_2=3 OR (a.ap_category_2=1 AND a.ap_participants LIKE ?))"; } private Sql selection(LegacyUser user,Query q,boolean statistics) { validate(q,statistics);var hqs=scope(user,q.headquarters()); var args=new ArrayList(hqs); String where="a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args)+" AND a.ap_execdate=? OR a.ap_enddate>=?)"; args.add(q.to().plusDays(1).atStartOfDay());args.add(q.from().atStartOfDay());args.add(q.from().atStartOfDay()); if(q.personId()!=null){where+=" AND (a.usr_id=? OR a.ap_participants LIKE ?)";args.add(q.personId());args.add(participant(q.personId()));} if(q.customerId()!=null){customer(user,q.customerId());where+=" AND a.cs_id=?";args.add(q.customerId());} Integer[] codes={q.status(),q.kind(),q.monitoring()};int[] categories={1,3,4}; for(int i=0;i(q.visibility()).containsAll(List.of(Visibility.values()))) { var clauses=new ArrayList(); for(var v:new LinkedHashSet<>(q.visibility()))switch(v) { case OWN_PRIVATE,OWN_PARTICIPANTS,OWN_PUBLIC -> { clauses.add("(a.usr_id=? AND a.ap_category_2=?)");args.add(user.userId());args.add(v==Visibility.OWN_PRIVATE?2:v==Visibility.OWN_PARTICIPANTS?1:3); } case INVITED -> { clauses.add("(a.usr_id<>? AND a.ap_participants LIKE ?)");args.add(user.userId());args.add(participant(user.userId())); } case OTHER_PUBLIC -> { clauses.add("(a.usr_id<>? AND a.ap_category_2=3 AND (a.ap_participants IS NULL OR a.ap_participants NOT LIKE ?))");args.add(user.userId());args.add(participant(user.userId())); } } where+=" AND ("+String.join(" OR ",clauses)+")"; } return new Sql(where,args.toArray()); } private static String selectColumns() { return String.join(",",COLUMNS.stream().map(c->"a."+c).toList()); } private static String joins() { return " FROM "+TABLE+" a LEFT JOIN user u ON u.usr_id=a.usr_id LEFT JOIN headquarters h ON h.hq_id=a.hq_id LEFT JOIN customer c ON c.cs_id=a.cs_id LEFT JOIN company co ON co.cmp_id=c.cmp_id"; } public Map list(LegacyUser user,Query query) { access(user);return db.readTransaction(()->{ var sql=selection(user,query,false); var rows=db.queryForList("SELECT "+selectColumns()+",CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS customer_label"+joins()+" WHERE "+sql.where()+" ORDER BY a.ap_execdate,a.ap_id LIMIT 5001",sql.args()); if(rows.size()>5000)throw bad("Mehr als 5000 Termine. Bitte Zeitraum oder Filter eingrenzen."); var names=participantNames(user,rows); return fields("items",rows.stream().map(r->view(user,r,names)).toList(),"total",rows.size(),"from",query.from(),"to",query.to()); }); } public Map get(LegacyUser user,long appointmentId) { access(user);return db.readTransaction(()->{ var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(appointmentId));args.addAll(hqs); String where="a.ap_id=? AND a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args); var row=one(db.queryForList("SELECT "+selectColumns()+",CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS author,h.hq_mnemonic AS headquarters,CONCAT_WS(' ',c.cs_eid,co.cmp_comp,co.cmp_comp2) AS customer_label"+joins()+" WHERE "+where,args.toArray())); return view(user,row,participantNames(user,List.of(row))); }); } private Map participantNames(LegacyUser user,List> rows) { var ids=new LinkedHashSet();for(var row:rows)ids.addAll(GroupAdministration.memberships(text(row,"ap_participants"))); var names=new HashMap();if(ids.isEmpty())return names; if(ids.size()>10000)throw bad("Zu viele Teilnehmer. Bitte den Zeitraum eingrenzen."); var all=new ArrayList<>(ids); for(int i=0;i(batch);args.add(user.headquartersId()); for(var r:db.queryForList("SELECT DISTINCT u.usr_id AS id,CONCAT_WS(' ',u.usr_firstname,u.usr_name) AS label FROM user u JOIN mandatorheadquarters m ON m.hq_id=u.hq_id JOIN mandatorheadquarters own ON own.md_id=m.md_id WHERE u.usr_id IN ("+marks(batch.size())+") AND own.hq_id=? AND own.md_id>0",args.toArray()))names.put(id(r,"id"),text(r,"label")); } return names; } static Map version(Map row) { var result=new LinkedHashMap();for(String key:COLUMNS)result.put(key,value(row,key));return result; } private static boolean canEdit(LegacyUser user,Map row) { return id(row,"usr_id")==user.userId()||user.hasRight(3)||GroupAdministration.memberships(text(row,"ap_participants")).contains(user.userId()); } private Map view(LegacyUser user,Map row,Map names) { var people=GroupAdministration.memberships(text(row,"ap_participants"));var confirmed=GroupAdministration.memberships(text(row,"ap_confirmed")); return fields("id",id(row,"ap_id"),"headquartersId",id(row,"hq_id"),"headquarters",text(row,"headquarters"),"authorId",id(row,"usr_id"),"author",text(row,"author"),"text",text(row,"ap_text"),"startsAt",dateTime(row,"ap_execdate"),"endsAt",dateTime(row,"ap_enddate"), "status",id(row,"ap_category_1"),"visibility",id(row,"ap_category_2"),"kind",id(row,"ap_category_3"),"monitoring",id(row,"ap_category_4"),"customerId",positiveId(row,"cs_id"),"customerLabel",text(row,"customer_label"), "participants",people.stream().map(id->fields("id",id,"label",names.getOrDefault(id,"Benutzer "+id),"confirmed",confirmed.contains(id))).toList(),"revision",revision(version(row)),"canEdit",canEdit(user,row),"canDelete",id(row,"usr_id")==user.userId()||user.hasRight(3),"canConfirm",people.contains(user.userId())&&!confirmed.contains(user.userId()),"canFinish",canEdit(user,row)&&id(row,"ap_category_4")==1, "hasLegacyRelation",id(row,"csc_id")>0||id(row,"pt_id")>0); } public Map statistics(LegacyUser user,Query query) { access(user);return db.readTransaction(()->{ var sql=selection(user,query,true); var rows=db.queryForList("SELECT a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4,COUNT(*) AS total FROM "+TABLE+" a WHERE "+sql.where()+" GROUP BY a.ap_category_1,a.ap_category_2,a.ap_category_3,a.ap_category_4 LIMIT 5001",sql.args()); if(rows.size()>5000)throw bad("Zu viele Statistikgruppen. Bitte Filter eingrenzen."); var metadata=categories();var categories=new ArrayList>(); for(int n=1;n<=4;n++) { var counts=new TreeMap();for(var row:rows)counts.merge(id(row,"ap_category_"+n),id(row,"total"),(entryValue, otherValue) -> Long.sum(entryValue, otherValue)); var labels=new HashMap();for(var option:metadata.get("category"+n)){long id=id(option,"id");labels.put(id,text(option,"label"));counts.putIfAbsent(id,0L);} categories.add(fields("category",n,"items",counts.entrySet().stream().map(e->fields("id",e.getKey(),"label",labels.getOrDefault(e.getKey(),"Unbekannt ("+e.getKey()+")"),"total",e.getValue())).toList())); } return fields("total",rows.stream().mapToLong(r->id(r,"total")).sum(),"categories",categories,"from",query.from(),"to",query.to()); }); } Map appointment(LegacyUser user,long appointmentId) { access(user);var hqs=allowedHeadquarters(user);var args=new ArrayList(List.of(appointmentId));args.addAll(hqs); String where="a.ap_id=? AND a.hq_id IN ("+marks(hqs.size())+") AND "+visible(user,args); return one(db.queryForList("SELECT "+selectColumns()+" FROM "+TABLE+" a WHERE "+where,args.toArray())); } static void editable(LegacyUser user,Map old) { if(!canEdit(user,old))throw new AccessDeniedException("Nur Ersteller, Teilnehmer oder Mitarbeiterverwaltung dürfen diesen Termin ändern."); } static void interval(LocalDateTime starts,LocalDateTime ends) { if(starts==null||ends==null||starts.getYear()<1000||ends.getYear()>9998||ends.isBefore(starts)||starts.plusYears(10).isBefore(ends))throw bad("Bitte einen gültigen Beginn und ein Ende ab Beginn angeben (höchstens zehn Jahre)."); if(starts.getSecond()!=0||starts.getNano()!=0||ends.getSecond()!=0||ends.getNano()!=0)throw bad("Termine bitte minutengenau eingeben."); } private Map values(LegacyUser user,Save request,Map old) { if(request==null)throw bad("Termin fehlt.");interval(request.startsAt(),request.endsAt()); String text=required(request.text(),65535,"Termintext");if(text.getBytes(StandardCharsets.UTF_8).length>65535)throw bad("Termintext ist zu lang (65535 UTF-8-Bytes)."); int[] values={request.status(),request.visibility(),request.kind(),request.monitoring()};var metadata=categories(); for(int i=0;i127||metadata.get("category"+(i+1)).stream().noneMatch(v->id(v,"id")==code))throw bad("Unbekannte Terminkategorie.");} if(!Set.of(1,2).contains(request.status())||!Set.of(1,2,3).contains(request.visibility())||!Set.of(0,1,2).contains(request.monitoring()))throw bad("Ungültiger Status, Sichtbarkeit oder Kontrolle."); if(request.customerId()!=null) { if(request.customerId()<=0)throw bad("Ungültige Kundenzuordnung."); if(old==null||request.customerId()!=id(old,"cs_id"))customer(user,request.customerId()); } if(old!=null&&(id(old,"csc_id")>0||id(old,"pt_id")>0)&&!Objects.equals(request.customerId(),positiveId(old,"cs_id")))throw bad("Die vorhandene Kostenstellen-/Kontaktverknüpfung verhindert eine andere Kundenzuordnung."); if(request.participants()==null||request.participants().size()>500||request.participants().stream().anyMatch(id->id==null||id<=0))throw bad("Bitte höchstens 500 gültige Teilnehmer auswählen."); var people=new TreeSet<>(request.participants());var allowed=new HashSet();for(var p:employees(allowedHeadquarters(user)))allowed.add(id(p,"id")); if(old!=null)allowed.addAll(GroupAdministration.memberships(text(old,"ap_participants"))); if(!allowed.containsAll(people))throw new AccessDeniedException("Ein Teilnehmer ist nicht für die freigegebenen Niederlassungen verfügbar."); var confirmed=old==null?new TreeSet():new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed"))); if(old!=null&&(!request.startsAt().equals(dateTime(old,"ap_execdate"))||!request.endsAt().equals(dateTime(old,"ap_enddate"))))confirmed.clear(); confirmed.retainAll(people); return fields("ap_text",text,"ap_execdate",request.startsAt(),"ap_enddate",request.endsAt(),"ap_category_1",request.status(),"ap_category_2",request.visibility(),"ap_category_3",request.kind(),"ap_category_4",request.monitoring(),"cs_id",request.customerId(),"ap_participants",ids(people),"ap_confirmed",ids(confirmed),"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0)); } private static Long positiveId(Map row,String key) { long value=id(row,key);return value>0?value:null; } static String ids(Collection ids) { return ids.isEmpty()?"":","+String.join(",",ids.stream().map(String::valueOf).toList())+","; } public Map create(LegacyUser user,Save request) { access(user);var fields=values(user,request,null); if(request.monitoring()==2)throw bad("Neue Termine bitte zunächst ohne Kontrolle oder mit aktiver Überwachung anlegen."); fields.putAll(fields("usr_id",user.userId(),"hq_id",user.headquartersId(),"ap_type",0,"csc_id",null,"pt_id",null)); long id=db.writeTransaction(()->db.insert(TABLE,fields));return fields("id",id); } public Map save(LegacyUser user,long appointmentId,Save request) { access(user);return db.writeTransaction(()->{ var old=appointment(user,appointmentId);editable(user,old);if(request==null)throw bad("Termin fehlt.");unchanged(version(old),request.revision()); var values=values(user,request,old); if(id(old,"ap_category_4")!=2&&request.monitoring()==2) { // Check the resulting customer/status too; changing the assignment must not bypass the report rule. var completed=new LinkedHashMap<>(old);completed.putAll(values);requireReport(user,completed); } update(old,values);return fields("id",appointmentId); }); } public void remove(LegacyUser user,long appointmentId,String revision) { access(user);db.writeTransaction(()->{ var old=appointment(user,appointmentId);if(id(old,"usr_id")!=user.userId())user.require(3);unchanged(version(old),revision); changed(db.update("DELETE FROM "+TABLE+" WHERE "+cas(),casArgs(old).toArray()));return null; }); } public Map resubmit(LegacyUser user,long appointmentId,Resubmission request) { access(user);if(request==null)throw bad("Wiedervorlage fehlt.");interval(request.startsAt(),request.endsAt()); var old=appointment(user,appointmentId);unchanged(version(old),request.revision()); // A resubmission creates a new appointment. Its source and source confirmations stay untouched. var values=new LinkedHashMap<>(version(old));values.remove("ap_id");values.put("usr_id",user.userId());values.put("hq_id",user.headquartersId()); values.put("ap_execdate",request.startsAt());values.put("ap_enddate",request.endsAt());values.put("ap_confirmed","");values.put("ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0)); if(id(old,"ap_category_4")==2)values.put("ap_category_4",1); long id=db.writeTransaction(()->db.insert(TABLE,values));return fields("id",id); } public void confirm(LegacyUser user,long appointmentId,String revision) { access(user);db.writeTransaction(()->{ var old=appointment(user,appointmentId); if(!GroupAdministration.memberships(text(old,"ap_participants")).contains(user.userId()))throw new AccessDeniedException("Nur eigene Teilnahme kann bestätigt werden."); unchanged(version(old),revision);var confirmed=new TreeSet<>(GroupAdministration.memberships(text(old,"ap_confirmed")));confirmed.add(user.userId()); update(old,fields("ap_confirmed",ids(confirmed)));return null; }); } public void finish(LegacyUser user,long appointmentId,String revision) { access(user);db.writeTransaction(()->{ var old=appointment(user,appointmentId);editable(user,old);unchanged(version(old),revision); if(id(old,"ap_category_4")!=1)throw bad("Nur ein überwachter Termin kann abgeschlossen werden.");requireReport(user,old); update(old,fields("ap_category_4",2,"ap_modifytime",LocalDateTime.now(ZoneId.of("Europe/Berlin")).withNano(0)));return null; }); } private void requireReport(LegacyUser user,Map appointment) { long customerId=id(appointment,"cs_id"); if(customerId>0&&id(appointment,"ap_category_1")==1&&catalog.enabled(0,"MASK_AP_WARNING_DISABLE_QUESTION_NO_REPORT_OF_SAME_DAY")) { var today=LocalDate.now(ZoneId.of("Europe/Berlin"));var args=new ArrayList(List.of(customerId,id(appointment,"hq_id"),today.atStartOfDay(),today.plusDays(1).atStartOfDay())); String sql="SELECT rp_id FROM `phoenix_group`.report_process WHERE rp_objtype='cs' AND rp_objid=? AND hq_id=? AND rp_createtime>=? AND rp_createtime old,Map values) { var args=new ArrayList<>(values.values());args.addAll(casArgs(old)); changed(db.update("UPDATE "+TABLE+" SET "+String.join(",",values.keySet().stream().map(c->c+"=?").toList())+" WHERE "+cas(),args.toArray())); } // MyISAM does not support transactional row locks. Include all source values in each mutation. private static String cas() { return String.join(" AND ",COLUMNS.stream().map(c->Set.of("ap_text","ap_participants","ap_confirmed").contains(c)?"BINARY "+c+" <=> BINARY ?":c+" <=> ?").toList()); } private static List casArgs(Map old) { var args=new ArrayList();for(String c:COLUMNS)args.add(value(old,c));return args; } private static void changed(int rows) { if(rows!=1)throw new ResponseStatusException(HttpStatus.CONFLICT,"Der Termin wurde zwischenzeitlich geändert oder gelöscht. Bitte neu laden."); } }