github.com/benoitkugler/goacve@v0.0.0-20201217100549-151ce6e55dc8/client/controllers/cont_suivi_dossiers.go (about) 1 package controllers 2 3 import ( 4 "errors" 5 "fmt" 6 "io/ioutil" 7 "net/http" 8 "path/filepath" 9 "sort" 10 "strings" 11 "time" 12 13 "github.com/benoitkugler/goACVE/server/core/documents" 14 "github.com/benoitkugler/goACVE/server/core/utils/importmail" 15 16 "github.com/benoitkugler/goACVE/server/core/apiserver" 17 dm "github.com/benoitkugler/goACVE/server/core/datamodel" 18 rd "github.com/benoitkugler/goACVE/server/core/rawdata" 19 ) 20 21 const ( 22 Indifferent CritereAttente = "" 23 // n'affiche pas les dossiers uniquement composé d'inscrits 24 MasquerNoAttente CritereAttente = "need_attente" 25 // n'affiche pas les dossiers uniquement composé d'attentes 26 MasquerOnlyAttente CritereAttente = "need_inscrit" 27 ) 28 29 // SuiviDossiers implémentes des fonctionnalités de communication avec les familles. 30 type SuiviDossiers struct { 31 Onglet OngletSuiviDossiers 32 main *MainController 33 Base *dm.BaseLocale 34 Etat EtatSuiviDossiers 35 EditRight bool // Droit de suppression/ajout/edition 36 Liste rd.Table 37 Header []rd.Header 38 } 39 40 type ButtonsSuiviDossiers struct { 41 Creer, Supprimer, EnvoiDocuments, Alertes EtatSideButton 42 } 43 44 type OngletSuiviDossiers interface { 45 baseOnglet 46 47 ShowPreviewFacture(fac dm.AccesFacture) 48 49 ConfirmeSupprimeFacture(html string) bool 50 // PrevisualiseMail(mail mails.MailRenderer, pjs []mails.PieceJointe, to string) (mailHtml string) 51 ConfirmeMail(fac dm.AccesFacture, messageKind rd.MessageKind) bool 52 ConfirmeSupprimeMessages(nb int) bool 53 } 54 55 type CritereAttente string 56 57 type EtatSuiviDossiers struct { 58 FactureCurrent rd.IId // uniquement dossier 59 MessageCurrent rd.OptionnalId // message affiché 60 Recherche string 61 CriteresTri dm.CriteresTri 62 CritereCamp rd.OptionnalId 63 CritereAcquite rd.Completion 64 CritereAttente CritereAttente 65 } 66 67 func NewSuiviDossiers(main *MainController, permission int) *SuiviDossiers { 68 s := SuiviDossiers{main: main, Base: main.Base, EditRight: permission >= 2, 69 Header: []rd.Header{ 70 {Field: dm.PersonneNomPrenom, Label: "Responsable"}, 71 {Field: dm.FactureParticipants, Label: "Participants"}, 72 {Field: dm.FactureCamps, Label: "Camps"}, 73 }} 74 s.resetData() 75 return &s 76 } 77 78 func (c *SuiviDossiers) resetData() { 79 cache := c.Base.NewCacheEtatFinancier() 80 cache2 := c.Base.ResoudMessages() 81 c.Liste = nil 82 for id, rawFac := range c.Base.Factures { 83 // on n'affiche uniquement les dossiers validés (et non les nouvelles inscriptions) 84 if !rawFac.IsValidated { 85 continue 86 } 87 88 acFac := c.Base.NewFacture(id) 89 match := true 90 if c.Etat.CritereCamp.IsNotNil() { 91 camps, _ := acFac.Camps(cache.FParticipants) 92 match = match && camps[c.Etat.CritereCamp.Int64] 93 } 94 if c.Etat.CritereAcquite != 0 { 95 bilan := acFac.EtatFinancier(cache, false) 96 match = match && (c.Etat.CritereAcquite == bilan.StatutPaiement()) 97 } 98 var hasAtLeastOneAttente, hasAtLeastOneInscrit bool 99 for _, part := range acFac.GetDossiers(cache.FParticipants) { 100 if part.RawData().ListeAttente.IsInscrit() { 101 hasAtLeastOneInscrit = true 102 } else { 103 hasAtLeastOneAttente = true 104 } 105 } 106 switch c.Etat.CritereAttente { 107 case MasquerNoAttente: 108 match = match && hasAtLeastOneAttente 109 case MasquerOnlyAttente: 110 match = match && hasAtLeastOneInscrit 111 } 112 113 if match { 114 c.Liste = append(c.Liste, acFac.AsItem(cache.FParticipants, cache2, cache.FPaiements)) 115 } 116 } 117 118 if strings.TrimSpace(c.Etat.Recherche) != "" { 119 // on enlève les camp de la recherche pour éviter les faux positifs 120 // (ex Grand) 121 c.Liste = dm.RechercheDetaillee(c.Liste, c.Etat.Recherche, c.Header[0:2]) 122 } 123 124 // besoin de déselectionner si absent de la Recherche : 125 if c.Etat.FactureCurrent != nil && !HasId(c.Liste, c.Etat.FactureCurrent) { 126 c.Etat.FactureCurrent = nil 127 } 128 129 dateDernierCamp := func(fac rd.Item) time.Time { 130 camps, _ := c.Base.NewFacture(fac.Id.Int64()).Camps(cache.FParticipants) 131 var last time.Time 132 for idCamp := range camps { 133 if dateDebut := c.Base.Camps[idCamp].DateDebut.Time(); last.Before(dateDebut) { 134 last = dateDebut 135 } 136 } 137 return last 138 } 139 140 // on tri systématiquement par date de séjour, avant 141 // de considérer les critères utilisateurs 142 sort.Slice(c.Liste, func(i, j int) bool { 143 di := dateDernierCamp(c.Liste[i]) 144 dj := dateDernierCamp(c.Liste[j]) 145 return di.After(dj) // plus récent en haut 146 }) 147 // s'il n'y a pas de critère, c'est un tri par Id, à éviter 148 if len(c.Etat.CriteresTri.Fields) > 0 { 149 dm.SortCriteres(c.Liste, c.Etat.CriteresTri) 150 } 151 } 152 153 func (c *SuiviDossiers) SideButtons() ButtonsSuiviDossiers { 154 bs := ButtonsSuiviDossiers{} 155 isActif := ButtonActivated 156 if c.Etat.FactureCurrent == nil { 157 isActif = ButtonPresent 158 } 159 if c.EditRight { 160 bs.Supprimer = isActif 161 bs.Creer = ButtonActivated 162 bs.EnvoiDocuments = ButtonActivated 163 } 164 bs.Alertes = ButtonActivated 165 return bs 166 } 167 168 func (c *SuiviDossiers) CreeFacture(idResponsable int64, participants rd.Table) { 169 currentsFac := c.Base.Factures 170 idParts := make(rd.Ids, 0, len(participants)) 171 // on vérifie que les participants ne sont pas déjà "attribués" 172 for _, partItem := range participants { 173 part := c.Base.NewParticipant(partItem.Id.Int64()) 174 if idF := part.RawData().IdFacture; idF.IsNotNil() { 175 if _, isIn := currentsFac[idF.Int64]; isIn { 176 c.main.ShowError(fmt.Errorf("Le participant %s (%s) est déjà associé à une facture !", 177 part.GetPersonne().RawData().NomPrenom(), part.GetCamp().RawData().Label())) 178 return 179 } 180 } 181 idParts = append(idParts, part.Id) 182 } 183 184 params := apiserver.CreateFactureIn{IdResponsable: idResponsable, IdParticipants: idParts} 185 c.main.ShowStandard("Création du dossier...", true) 186 var out apiserver.CreateFactureOut 187 err := requete(apiserver.UrlFactures, http.MethodPut, params, &out) 188 if err != nil { 189 c.main.ShowError(err) 190 return 191 } 192 193 c.Base.Factures[out.Facture.Id] = out.Facture 194 for _, part := range out.Participants { 195 c.Base.Participants[part.Id] = part 196 } 197 c.Base.Messages[out.Message.Id] = out.Message 198 c.Etat.FactureCurrent = rd.IdFacture(out.Facture.Id) 199 c.main.ShowStandard("Dossier créé avec succès.", false) 200 c.main.ResetAllControllers() 201 c.ResetRender() 202 } 203 204 // UpdateFacture met à jour de manière synchrone la facture donnée. 205 func (c *SuiviDossiers) UpdateFacture(fac rd.Facture) *rd.Facture { 206 c.main.ShowStandard("Mise à jour du dossier...", true) 207 out := new(rd.Facture) 208 err := requete(apiserver.UrlFactures, http.MethodPost, fac, out) 209 if err != nil { 210 c.main.ShowError(err) 211 return nil 212 } 213 c.Base.Factures[out.Id] = *out 214 c.main.ShowStandard("Dossier mis à jour avec succès.", false) 215 c.Reset() 216 return out 217 } 218 219 // SupprimeFacture supprime la facture courante sur le serveur, après confirmation. 220 func (c *SuiviDossiers) SupprimeFacture() { 221 if c.Etat.FactureCurrent == nil { 222 return 223 } 224 fac := c.Base.NewFacture(c.Etat.FactureCurrent.Int64()) 225 paiements, participants := fac.GetPaiements(nil, true), fac.GetDossiers(nil) 226 msg := fmt.Sprintf("Confirmez-vous la suppression du dossier %s ? <br/>", fac.GetPersonne().RawData().NomPrenom()) 227 if len(paiements) > 1 { 228 msg += fmt.Sprintf("<i>%d</i> paiements associés seront <b>supprimés</b>!<br/>", len(paiements)) 229 } else if len(paiements) == 1 { 230 msg += "un paiement associé sera <b>supprimé</b>!<br/>" 231 } 232 if len(participants) > 1 { 233 msg += fmt.Sprintf("%d participants sont liés à ce dossier et seront déliés (mais non supprimés). <br/>", len(participants)) 234 } else if len(participants) == 1 { 235 msg += "un participant est lié à ce dossier et sera délié (mais non supprimé). <br/>" 236 237 } 238 msg += "Attention, l'espace personnel associé sera <b>détruit</b> !" 239 240 if !c.Onglet.ConfirmeSupprimeFacture(msg) { 241 c.main.ShowStandard("Suppression annulée", false) 242 return 243 } 244 245 job := func() (interface{}, error) { 246 var out apiserver.DeleteFactureOut 247 err := requete(apiserver.UrlFactures, http.MethodDelete, IdAsParams(fac.Id), &out) 248 return out, err 249 } 250 onSuccess := func(_out interface{}) { 251 out := _out.(apiserver.DeleteFactureOut) 252 for _, idPaiement := range out.DeletedPaiements { 253 delete(c.Base.Paiements, idPaiement) 254 } 255 for id, part := range out.Participants { 256 c.Base.Participants[id] = part 257 } 258 c.Base.DeleteFacture(fac.Id) 259 msg = fmt.Sprintf("Facture bien supprimée.") 260 if L := len(out.DeletedPaiements); L > 0 { 261 msg += fmt.Sprintf(" %d paiement(s) lié(s) aussi supprimé(s).", L) 262 } 263 c.main.ShowStandard(msg, false) 264 c.main.ResetAllControllers() 265 } 266 267 c.Etat.FactureCurrent = nil 268 c.main.ShowStandard("Suppression du dossier en cours...", true) 269 c.main.Background.Run(job, onSuccess) 270 } 271 272 // AjouteParticipant ajoute le participant à la facture courante. 273 func (c *SuiviDossiers) AjouteParticipant(participant dm.AccesParticipant) *dm.AccesParticipant { 274 if c.Etat.FactureCurrent == nil { 275 return nil 276 } 277 278 raw := participant.RawData() 279 if !raw.IdFacture.IsNil() { 280 c.main.ShowError(fmt.Errorf("Le participant %s (%s) est déjà lié à un dossier !", 281 participant.GetPersonne().RawData().NomPrenom(), participant.GetCamp().RawData().Label())) 282 return nil 283 } 284 idFac := c.Etat.FactureCurrent.Int64() 285 raw.IdFacture = rd.NewOptionnalId(idFac) 286 return c.main.Controllers.Camps.UpdateParticipant(raw) 287 } 288 289 // UpdateParticipant est synchrone 290 func (c *SuiviDossiers) UpdateParticipant(participant rd.Participant) { 291 c.main.Controllers.Camps.UpdateParticipant(participant) 292 } 293 294 // RetireParticipant enlève le participant donné de la facture courante, de manière SYNCHRONE. 295 // Si `supprime` vaut true, le participant est supprimé. 296 func (c *SuiviDossiers) RetireParticipant(participant dm.AccesParticipant, supprime bool) { 297 if supprime { 298 c.main.Controllers.Camps.SupprimeParticipant(participant, true) 299 return 300 } 301 raw := participant.RawData() 302 raw.IdFacture = rd.OptionnalId{} 303 c.main.Controllers.Camps.UpdateParticipant(raw) 304 } 305 306 // AjouteAide est synchrone. Gère l'erreur et renvoi nil. 307 func (c *SuiviDossiers) AjouteAide(aide rd.Aide) *rd.Aide { 308 created, err := JobCreeAide(aide) 309 if err != nil { 310 c.main.ShowError(err) 311 return nil 312 } 313 c.Base.Aides[created.Id] = created 314 return &created 315 } 316 317 // UpdateAide est synchrone 318 func (c *SuiviDossiers) UpdateAide(aide rd.Aide) { 319 c.main.Controllers.Aides.UpdateAide(aide) 320 } 321 322 // SupprimeAide est asynchrone 323 func (c *SuiviDossiers) SupprimeAide(aide int64) { 324 c.main.Controllers.Aides.SupprimeAide(aide) 325 } 326 327 // SelectFacture sélectionne la facture donnée. Si nécessaire, 328 // remet à zéro les critères. 329 func (c *SuiviDossiers) SelectFacture(fac dm.AccesFacture) { 330 if _, isIn := c.Base.Factures[fac.Id]; !isIn { 331 c.main.ShowError(fmt.Errorf("Le dossier demandé (id %d) n'est pas présent dans la base !", fac.Id)) 332 return 333 } 334 335 c.Etat.FactureCurrent = rd.IdFacture(fac.Id) 336 if !HasId(c.Liste, c.Etat.FactureCurrent) { // restreint l'affichage pour des raisons de performances 337 pers := fac.GetPersonne().RawData() 338 c.Etat.Recherche = pers.NomPrenom().String() 339 c.Etat.CritereAcquite = 0 340 c.Etat.CritereCamp = rd.NullId() 341 c.resetData() 342 } 343 c.Onglet.GrabFocus() 344 c.ResetRender() 345 } 346 347 // GenereExportDocument génère la facture au format .pdf, l'enregistre dans local, 348 // et renvoie le chemin. 349 // Les aides et paiements invalides ne sont pas affichés. 350 func (m *MainController) GenereExportDocument(meta documents.DynamicDocument) string { 351 b, err := meta.Generate() 352 if err != nil { 353 m.ShowError(fmt.Errorf("Erreur pendant la génération du document : %s", err)) 354 return "" 355 } 356 path, err := filepath.Abs(filepath.Join(LocalFolder, meta.FileName())) 357 if err != nil { 358 m.ShowError(fmt.Errorf("Impossible d'enregistrer le document: %s", err)) 359 return "" 360 } 361 if err = ioutil.WriteFile(path, b, 0666); err != nil { 362 m.ShowError(fmt.Errorf("Impossible d'enregistrer le document: %s", err)) 363 return "" 364 } 365 m.ShowStandard(fmt.Sprintf("Document généré dans %s", path), false) 366 return path 367 } 368 369 func (c *SuiviDossiers) RenderAttestation() string { 370 id := c.Etat.FactureCurrent 371 if id == nil { 372 c.main.ShowError(errors.New("Aucun dossier courant !")) 373 return "" 374 } 375 fac := c.Base.NewFacture(id.Int64()) 376 meta := documents.Presence{ 377 Destinataire: fac.GetPersonne().RawData().ToDestinataire(), 378 // on ne sélectionne pas la liste d'attente 379 Participants: fac.EtatFinancier(dm.CacheEtatFinancier{}, false).Participants, 380 } 381 path := c.Main().GenereExportDocument(meta) 382 return path 383 } 384 385 func (c *SuiviDossiers) HasNotifications() bool { 386 a, f := c.Notifications() 387 return len(a)+len(f) > 0 388 } 389 390 // Notifications renvoie les messages non lus et les aides déclarées 391 // sur les espaces persos. 392 func (c *SuiviDossiers) Notifications() ([]dm.AccesAide, []dm.AccesFacture) { 393 var aides []dm.AccesAide 394 for _, aide := range c.Base.Aides { 395 if !aide.Valide { 396 aides = append(aides, c.Base.NewAide(aide.Id)) 397 } 398 } 399 sort.Slice(aides, func(i, j int) bool { // force l'idempotance 400 return aides[i].Id < aides[j].Id 401 }) 402 sort.SliceStable(aides, func(i, j int) bool { 403 di := aides[i].GetParticipant().GetCamp().RawData().DateDebut.Time() 404 dj := aides[j].GetParticipant().GetCamp().RawData().DateDebut.Time() 405 return di.After(dj) // camp les plus récent au début 406 }) 407 idsFactures := rd.NewSet() 408 factureToMessage := map[int64]rd.Message{} // on garde le message concerné pour trier 409 for _, message := range c.Base.Messages { 410 if message.Kind == rd.MResponsable && !message.Vu { 411 // on ajoute le dossier correspondant 412 idsFactures.Add(message.IdFacture) 413 // si deux nouveaux messages appartiennent au même dossier, 414 // on utilise le plus récent pour trier 415 // dans la plupart des cas, currentMessage est zéro, et la condition est vraie 416 if currentMessage := factureToMessage[message.Id]; message.Created.Time().After(currentMessage.Created.Time()) { 417 factureToMessage[message.Id] = message 418 } 419 } 420 } 421 var factures []dm.AccesFacture 422 for id := range idsFactures { 423 factures = append(factures, c.Base.NewFacture(id)) 424 } 425 sort.Slice(factures, func(i, j int) bool { // force l'idempotance 426 return factures[i].Id < factures[j].Id 427 }) 428 sort.SliceStable(factures, func(i, j int) bool { 429 di := factureToMessage[factures[i].Id].Created.Time() 430 dj := factureToMessage[factures[j].Id].Created.Time() 431 return di.After(dj) 432 }) 433 return aides, factures 434 } 435 436 // ImportMail extrait de la source du mail un Message 437 // L'expéditeur détermine le dossier 438 func (c *SuiviDossiers) ImportMail(source string) (importmail.Mail, rd.OptionnalId, error) { 439 mail, err := importmail.NewMail(source) 440 if err != nil { 441 err = fmt.Errorf("Impossible de décoder l'email. Détails : <br/> <i>%v</i>", err) 442 return mail, rd.NullId(), err 443 } 444 candidats := rd.NewSet() 445 // on cherche l'adresse mail dans les responsables 446 for idFacture := range c.Base.Factures { 447 fac := c.Base.NewFacture(idFacture) 448 if fac.GetPersonne().RawData().Mail.ToLower() == mail.From { 449 candidats.Add(idFacture) 450 } 451 } 452 // puis, si besoin, dans les mails en copie 453 if len(candidats) == 0 { 454 for _, fac := range c.Base.Factures { 455 for _, mailCopie := range fac.CopiesMails { 456 if strings.TrimSpace(strings.ToLower(mailCopie)) == mail.From { 457 candidats.Add(fac.Id) 458 } 459 } 460 } 461 } 462 // choix du plus récent 463 cache1, cache2 := c.Base.ResoudMessages(), c.Base.ResoudPaiements() 464 ids := candidats.Keys() 465 sort.Slice(ids, func(i, j int) bool { 466 fi, fj := c.Base.NewFacture(ids[i]), c.Base.NewFacture(ids[j]) 467 lastI, lastJ := fi.GetEtat(cache1, cache2).LastModified(), fj.GetEtat(cache1, cache2).LastModified() 468 return lastI.Time().Before(lastJ.Time()) 469 }) 470 var idHint rd.OptionnalId 471 if L := len(ids); L > 0 { 472 idHint = rd.NewOptionnalId(ids[L-1]) 473 } 474 return mail, idHint, nil 475 } 476 477 func (c *SuiviDossiers) CreateMessageResponsable(content rd.String, idRespo int64, imported importmail.Mail) { 478 imported.Content = content.String() 479 if imported.Received.IsZero() { // absent du mail -> on utilise la date courante (à modifier ?) 480 imported.Received = time.Now() 481 } 482 message, contenu := imported.AsMessage(idRespo) 483 params := apiserver.CreateMessageMessage{Message: message, MessageMessage: contenu} 484 job := func() (interface{}, error) { 485 var out apiserver.CreateMessageMessage 486 err := requete(apiserver.UrlMessages, http.MethodPut, params, &out) 487 return out, err 488 } 489 onSuccess := func(_out interface{}) { 490 out := _out.(apiserver.CreateMessageMessage) 491 c.Base.Messages[out.Message.Id] = out.Message 492 c.Base.MessageMessages[out.MessageMessage.IdMessage] = out.MessageMessage 493 c.main.ShowStandard("Message ajouté avec succès.", false) 494 c.Reset() 495 } 496 c.main.ShowStandard("Ajout du message en cours...", true) 497 c.main.Background.Run(job, onSuccess) 498 } 499 500 func (c *SuiviDossiers) FusionneDossiers(params apiserver.FusionneFacturesIn) { 501 job := func() (interface{}, error) { 502 var out apiserver.FusionneFacturesOut 503 err := requete(apiserver.UrlFacturesFusion, http.MethodPost, params, &out) 504 return out, err 505 } 506 onSuccess := func(_out interface{}) { 507 out := _out.(apiserver.FusionneFacturesOut) 508 c.Base.ApplyFusionneFactures(out) 509 c.main.ShowStandard("Dossier fusionné avec succés.", false) 510 c.main.ResetAllControllers() 511 } 512 c.main.ShowStandard("Fusion du dossier...", true) 513 c.main.Background.Run(job, onSuccess) 514 }