Bladeren bron

Refonte du script gf2analytique

olivier.massot 7 jaren geleden
bovenliggende
commit
1b8f3b94c2
3 gewijzigde bestanden met toevoegingen van 197 en 243 verwijderingen
  1. 4 0
      core/db.py
  2. 191 241
      gf2analytique.py
  3. 2 2
      logging.yaml

+ 4 - 0
core/db.py

@@ -60,6 +60,10 @@ class CustomDb(pypyodbc.Connection):
         except StopIteration:
             return None
 
+    def exists(self, sql, *args):
+        """ return True if the sql command retrieves records """
+        return (self.first(sql, *args) is not None)
+
     def execute(self, sql, *args):
         cursor = self.cursor()
         args = [sql, tuple(args)] if args else [sql]

+ 191 - 241
gf2analytique.py

@@ -3,20 +3,16 @@
     la base de données ASTRE-GF vers les tables de la base Analytique
     du Parc Départemental d'Erstein
 
-    En cas d'erreur avec les données importées:
-    1. une tentative d'autocorrection est effectuée
-    2. Si les données sont toujours invalides, une ligne est ajoutée au fichier .\\work\\gf2analytique\\err.csv
-       pour une correction manuelle.
+    L'import se déroule en trois étapes:
 
-    IMPORTANT: Si le fichier 'err.csv' contient des lignes, le script tentera d'importer
-               ces lignes à la place de celles issues de Astre Gf.
-               Pour forcer un imprt depuis AstreGf, supprimez le fichier 'err.csv'
-
-    Info: Les données sont obtenues via le web service CG67.AstreGf
+    1- chargement des données issues de Astre (via le web service CG67.AstreGf) dans le fichier /work/gf2analytique/import.csv
+    2- Contrôle de la validité des données, prompt éventuel pour une correction des donneés
+    3- Une fois les données valides, import dans Analytique
 
 '''
 import logging
 import re
+import sys
 
 from path import Path  # @UnusedImport
 
@@ -31,15 +27,18 @@ logconf.start("gf2analytique", logging.DEBUG)
 # # POUR TESTER, décommenter les lignes suivantes
 ##-----------------------------------------------
 
-logger.warning("<<<<<<<<<<<<<<   Mode TEST   >>>>>>>>>>>>>>>>>")
-GfWebservice._url = r"http://webservices-t.bas-rhin.fr/CG67.AstreGF.WebServices/public/WsPDE.asmx"
-AnalytiqueDb._path = Path(r"\\h2o\local\4-transversal\BDD\mdb_test\Db_analytique.mdb")
+# logger.warning("<<<<<<<<<<<<<<   Mode TEST   >>>>>>>>>>>>>>>>>")
+# GfWebservice._url = r"http://webservices-t.bas-rhin.fr/CG67.AstreGF.WebServices/public/WsPDE.asmx"
+# AnalytiqueDb._path = Path(r"\\h2o\local\4-transversal\BDD\mdb_test\Db_analytique.mdb")
 
 ##-----------------------------------------------
 
+# *** Initialisation
+logger.info("Initialisation...")
 
-
-logger.info("Initialization")
+no_prompt = ("-n" in sys.argv)
+if no_prompt:
+    logger.info("> Lancé en mode automatique (sans interruption)")
 
 # Connect to factures.mdb
 analytique_db = AnalytiqueDb(autocommit=False)
@@ -49,80 +48,57 @@ ws = GfWebservice("GetPDEFactures")
 
 # Make the working directory
 workdir = mk_workdir("gf2analytique")
-errfile = workdir / "err.csv"
-
-class AlreadyImported(Exception):
-    pass
-
-class NotImported(Exception):
-    pass
+importfile = workdir / "import.csv"
 
-class InvalidData(Exception):
-    pass
+# > Supprime le fichier d'import s'il existe, et le recréé avec la ligne d'en-tête
+if importfile.exists():
+    logger.debug("Supprime le fichier %s", importfile)
+    importfile.remove()
 
-class InvalidAxe(Exception):
-    pass
 
 class Facture():
-    WS_FIELDS = ["numExBudget", "codeColl", "codeBudg", "numEnv", "codeSection", "typeMvt", "numMandat", "numLiqMandat",
+    """ Modèle de données d'une facture """
+    _FIELDS = ["numExBudget", "codeColl", "codeBudg", "numEnv", "codeSection", "typeMvt", "numMandat", "numLiqMandat",
                  "numLigneMandat", "codeAxe", "libAxe", "codeCout", "libCout", "dateMandat", "numBj", "numTiers",
                  "libRai", "refIntMandat", "codePeriode", "dateDepDelai", "typeNomencMarche", "mntTtcMandat",
                  "mntTvaMandat", "mntVent"]
-    def __init__(self):
-        self._factureId = None
 
-        for fld in self.WS_FIELDS:
+    def __init__(self):
+        """ Génère un objet facture vide, les propriétés sont générrées automatiquement à partir de la variable _FIELDS"""
+        for fld in self._FIELDS:
             setattr(self, fld, None)
 
-    @property
-    def factureId(self):
-        if self._factureId is None:
-            try:
-                self._factureId = self._get_facture_id()
-            except (KeyError, AttributeError, TypeError):
-                raise NotImported()
-        return self._factureId
-
     @classmethod
-    def from_webservice(cls, wsdata):
+    def from_dict(cls, data):
+        """ Retourne un objet Facture à partir d'un dictionnaire de données """
         facture = cls()
-        for key, value in wsdata.items():
+        for key, value in data.items():
             setattr(facture, key, value)
-        facture.autocorrection()
         return facture
 
-    @classmethod
-    def from_errfile(cls, line):
-        return cls.from_webservice(dict(zip(cls.WS_FIELDS, line.split("\t"))))
-
-    def is_imported(self):
-        try:
-            return self.factureId > 0
-        except NotImported:
-            return False
-
-    def _init_errfile(self):
-        try:
-            with open(errfile, 'r') as f:
-                if f.read(100):
-                    # File already exists and is not empty
-                    return
-        except FileNotFoundError:
-            pass
+    def to_csv(self):
+        """ Renvoie une chaine de caractère correspondant aux données de la Facture au format CSV
+        Le séparateur est une tabulation (car c'est un caractère interdit dans Access) """
+        return "\t".join([str(getattr(self, field)).replace("\t", " ") for field in self._FIELDS] + ["\n"])
 
-        firstline = "\t".join(self.WS_FIELDS + ["\n"])
-        with open(errfile, 'a') as f:
-            f.write(firstline)
-
-    def dump_to_err(self):
-        self._init_errfile()
+    @classmethod
+    def from_csv(cls, line):
+        """ Retourne un objet Facture à partir d'une ligne de texte au format CSV
+        Le séparateur est une tabulation (car c'est un caractère interdit dans Access) """
+        return cls.from_dict(dict(zip(cls._FIELDS, line.split("\t"))))
 
-        line = "\t".join([str(getattr(self, field)).replace("\t", " ") for field in self.WS_FIELDS] + ["\n"])
+    def est_importee(self):
+        """ Renvoie True si la facture a déjà été importée dans Analytique
+        ATTENTION: en l'absence d'identifiants uniques, il est difficile de contrôler de manière certaine si une ligne a déjà été importée.
+        C'est pour cette raison que les données sont importées 'par blocs' """
 
-        with open(errfile, 'a') as f:
-            f.write(line)
+        sql = """SELECT dblFactureId FROM tbl_Factures
+                WHERE intExercice = {} AND strLiquidation = '{}' AND strEngagement = '{}' AND strService='7710'
+              """.format(self.numExBudget, self.numLiqMandat, self.numMandat)
+        return analytique_db.exists(sql)
 
     def autocorrection(self):
+        """ Procède à certaines corrections automatiques sur les données de la facture """
         # correction auto des codes chantiers
         if self.codeAxe == "AFFAI" and re.match(r"\d{2}5\d{3}", self.codeCout):
             self.codeCout += "/1"
@@ -141,223 +117,197 @@ class Facture():
     def is_valid(self):
         """ controle la validité des données d'une facture """
         if not int(self.numExBudget) > 2000:
-            logger.warning("Exercice budgetaire invalide: %s", self.numExBudget)
+            logger.error("Exercice budgetaire invalide: %s", self.numExBudget)
             return False
         if self.codeColl != "CG67":
-            logger.warning("Code collectivité invalide: %s", self.codeColl)
+            logger.error("Code collectivité invalide: %s", self.codeColl)
             return False
         if self.codeBudg != "02":
-            logger.warning("Code budgetaire invalide: %s", self.codeBudg)
+            logger.error("Code budgetaire invalide: %s", self.codeBudg)
             return False
         if self.codeAxe == "ENGIN":
             # Controle l'existence du materiel
             if not analytique_db.first("SELECT intlMaterielID FROM tbl_materiel WHERE txtMateriel='{}'".format(self.codeCout)):
-                logger.warning("Le materiel n'existe pas: %s", self.codeCout)
+                logger.error("Le materiel n'existe pas: %s", self.codeCout)
                 return False
         elif self.codeAxe == "AFFAI":
             # Controle l'existence de l'affaire
             if not analytique_db.first("SELECT dblAffaireId FROM tbl_Affaires WHERE strLiaisonControle='{}'".format(self.codeCout)):
-                logger.warning("L'affaire n'existe pas: %s", self.codeCout)
+                logger.error("L'affaire n'existe pas: %s", self.codeCout)
                 return False
         else:
             # CodeAxe invalide
-            logger.warning("Code axe inconnu: %s", self.codeAxe)
+            logger.error("Code axe inconnu: %s", self.codeAxe)
             return False
         return True
 
-    def send_to_db(self):
 
-        if self.is_imported():
-            raise AlreadyImported()
 
-        if not self.is_valid():
-            raise InvalidData()
+# *** 1- Parcourt les factures renvoyées par le webservice, et stoque toutes les lignes non-importées dans Analytique dans un fichier import.csv
+logger.info("Parcourt les données fournies par le webservice")
+logger.info("(les ligne à importer sont ajoutées au fichier %s)", importfile)
 
-        self._insert_factures()
+logger.debug("Génère le fichier %s", importfile)
+firstline = "\t".join(Facture._FIELDS + ["\n"])
+with open(importfile, 'w+') as f:
+    f.write(firstline)
 
-        if self.codeAxe == "ENGIN":
-            self._insert_factures_engins()
 
-        elif self.codeAxe == "AFFAI":
-            self._insert_factures_affaires()
+for data in ws:
+    # Génère la facture à partir des données fournies par le web-service
+    facture = Facture.from_dict(data)
 
-        self._insert_mandatement()
+    # Contrôle si la facture est déjà importée. Si c'est le cas, passe à la facture suivante.
+    if facture.est_importee():
+        continue
 
-        analytique_db.commit()
+    logger.info("* Facture %s/%s/%s: import", facture.numExBudget, facture.numMandat, facture.numLiqMandat)
 
-        logger.info("* imported: %s", self.factureId)
+    # procède à une auto-correction des données
+    facture.autocorrection()
 
-    def _get_facture_id(self):
-        sql = """SELECT dblFactureId FROM tbl_Factures
-                    WHERE intExercice = {}
-                    AND strLiquidation = '{}'
-                    AND strEngagement = '{}'
-                    AND strService='7710'
-                """.format(self.numExBudget,
-                           self.numLiqMandat,
-                           self.numMandat)
-
-        factureId = analytique_db.first(sql).dblFactureId
-        return factureId
-
-    def _insert_factures(self):
-        sql = """INSERT INTO tbl_Factures ( intExercice,
-                                            strLiquidation,
-                                            intLiquidationLigne,
-                                            strEngagement,
-                                            strEnveloppe,
-                                            strService,
-                                            strTiers,
-                                            strTiersLibelle,
-                                            strMotsClefs,
-                                            dtmDeb,
-                                            intOperation,
-                                            strNomenclature0,
-                                            strAXE,
-                                            strCentreCout,
-                                            strObjet,
-                                            dblMontantTotal,
-                                            dblMontantTVA,
-                                            strORIGINE_DONNEES
+    # Ajoute les données au format CSV au fichier d'import
+    with open(importfile, 'a') as f:
+        f.write(facture.to_csv())
+
+
+
+# *** 2- Contrôle les données. En cas d'erreur, le script est interrompu et la position et la description des erreurs sont loggés.
+errors = -1
+
+while errors:
+    errors = 0
+    logger.info("Contrôle des données")
+
+    # Parcourt les lignes du fichier d'import, et teste la validité de chacune.
+    with open(importfile) as f:
+        next(f)  # saute la première ligne
+
+        for line in f:
+            facture = Facture.from_csv(line)
+
+            if not facture.is_valid():
+                errors += 1
+
+    if errors:
+        logger.error("<!> Une ou plusieurs erreurs ont été détectées, voir le fichier de log pour plus d'information <!>")
+        logger.info("Veuillez corriger les données du fichier %s", importfile)
+
+        # En cas d'erreur(s), deux possibilités:
+        # - Le script a été lancé en mode sans interruption avec l'option '-n', on interrompt le script.
+        # - Le script a été lancé normalement, sans option: on attend une correction manuelle de l'utilisateur.
+        if no_prompt:
+            sys.exit(errors)
+        else:
+            input("Presser une touche pour continuer...")
+
+
+logger.info("Les données sont valides.")
+
+# 3- Si toutes les données sont valides, parcourt les lignes du fichier import.csv et les insère dans la table tbl_Facture.
+logger.info("Mise à jour des tables de %s", AnalytiqueDb._path)
+
+with open(importfile) as f:
+    next(f)  # saute la première ligne
+
+    for line in f:
+        facture = Facture.from_csv(line)
+
+        logger.info("* Facture %s/%s/%s: traitement", facture.numExBudget, facture.numMandat, facture.numLiqMandat)
+        # NB: les données ne sont committées qu'aprés l'exécution de toutes les requêtes suivantes
+
+        logger.info("> mise à jour de tbl_Factures")
+
+        # Insère les données dans la table tbl_Factures
+        sql = """INSERT INTO tbl_Factures ( intExercice, strLiquidation, intLiquidationLigne, strEngagement,
+                                            strEnveloppe, strService, strTiers, strTiersLibelle, strMotsClefs,
+                                            dtmDeb, intOperation, strNomenclature0, strAXE, strCentreCout,
+                                            strObjet, dblMontantTotal, dblMontantTVA, strORIGINE_DONNEES
                                         )
-                  VALUES ({intExercice},
-                          '{strLiquidation}',
-                          {intLiquidationLigne},
-                          '{strEngagement}',
-                          '{strEnveloppe}',
-                          '{strService}',
-                          '{strTiers}',
-                          '{strTiersLibelle}',
-                          '{strMotsClefs}',
-                          #{dtmDeb}#,
-                          {intOperation},
-                          '{strNomenclature0}',
-                          '{strAxe}',
-                          '{strCentreCout}',
-                          '{strObjet}',
-                          {dblMontantTotal},
-                          {dblMontantTVA},
-                          '{strORIGINE_DONNEES}'
-                          )
+                  VALUES ({intExercice}, '{strLiquidation}', {intLiquidationLigne}, '{strEngagement}',
+                          '{strEnveloppe}', '{strService}', '{strTiers}', '{strTiersLibelle}', '{strMotsClefs}',
+                          #{dtmDeb}#, {intOperation}, '{strNomenclature0}', '{strAxe}', '{strCentreCout}',
+                          '{strObjet}', {dblMontantTotal}, {dblMontantTVA}, '{strORIGINE_DONNEES}')
               """.format(
-                         intExercice=self.numExBudget,
-                         strLiquidation=self.numLiqMandat,
-                         intLiquidationLigne=self.numLigneMandat,
-                         strEngagement=self.numMandat,
-                         strEnveloppe=self.numEnv,
+                         intExercice=facture.numExBudget,
+                         strLiquidation=facture.numLiqMandat,
+                         intLiquidationLigne=facture.numLigneMandat,
+                         strEngagement=facture.numMandat,
+                         strEnveloppe=facture.numEnv,
                          strService='7710',
-                         strTiers=self.numTiers,
-                         strTiersLibelle=self.libRai,
-                         strMotsClefs=AnalytiqueDb.nz(self.refIntMandat),
-                         dtmDeb=AnalytiqueDb.format_date(self.dateDepDelai),
-                         intOperation=AnalytiqueDb.nz(self.codePeriode, "Null"),
-                         strNomenclature0=self.typeNomencMarche,
-                         strAxe=self.codeAxe,
-                         strCentreCout=self.codeCout,
-                         strObjet=AnalytiqueDb.format_date(self.dateMandat, out_format="%d/%m/%Y"),
-                         dblMontantTVA=self.mntTvaMandat,
-                         dblMontantTotal=self.mntVent,
+                         strTiers=facture.numTiers,
+                         strTiersLibelle=facture.libRai,
+                         strMotsClefs=AnalytiqueDb.nz(facture.refIntMandat),
+                         dtmDeb=AnalytiqueDb.format_date(facture.dateDepDelai),
+                         intOperation=AnalytiqueDb.nz(facture.codePeriode, "Null"),
+                         strNomenclature0=facture.typeNomencMarche,
+                         strAxe=facture.codeAxe,
+                         strCentreCout=facture.codeCout,
+                         strObjet=AnalytiqueDb.format_date(facture.dateMandat, out_format="%d/%m/%Y"),
+                         dblMontantTVA=facture.mntTvaMandat,
+                         dblMontantTotal=facture.mntVent,
                          strORIGINE_DONNEES='ASTRE'
                          )
         logger.debug("> %s", sql)
         analytique_db.execute(sql)
 
-    def _insert_factures_engins(self):
-        if self.codeAxe != "ENGIN":
-            raise InvalidAxe()
-
-        materiel = analytique_db.first("SELECT intlMaterielID FROM tbl_Materiel WHERE [txtMateriel]='{}'".format(self.codeCout))
-        materielId = materiel.intlMaterielID if materiel else '859'
-        logger.debug("retrieve intlMaterielID: %s", materielId)
-
-        sql = """INSERT INTO tbl_Facture_Engin ( intlMaterielID, txtMateriel, dblFactureId, strLibelle, dblMontant, strType )
-                VALUES ({}, '{}', {}, '{}', {}, '{}')
-        """.format(materielId,
-                   self.codeCout,
-                   self.factureId,
-                   AnalytiqueDb.nz(self.libCout),
-                   self.mntVent,
-                   self.libRai
-                   )
-        logger.debug("> %s", sql)
-        analytique_db.execute(sql)
+        facture.factureId = analytique_db.first("SELECT TOP 1 dblFactureId FROM tbl_Factures ORDER BY dblFactureId DESC").dblFactureId
 
-    def _insert_factures_affaires(self):
-        if self.codeAxe != "AFFAI":
-            raise InvalidAxe()
-
-        sql = """INSERT INTO tbl_Facture_Affaire ( strAffaireId, dblFactureId, strLibelle, dblMontant, strType )
-              VALUES ('{}', {}, '{}', {}, '{}')
-              """.format(self.codeCout,
-                         self.factureId,
-                         self.libRai ,
-                         self.mntVent,
-                         AnalytiqueDb.nz(self.libCout),
-                         )
-        logger.debug("> %s", sql)
-        analytique_db.execute(sql)
 
-    def _insert_mandatement(self):
+        if facture.codeAxe == "ENGIN":
+            # La ligne concerne un engin: insère les données dans la table tbl_Facture_Engin
+            logger.info("> mise à jour de tbl_Facture_Engin")
+
+            materiel = analytique_db.first("SELECT intlMaterielID FROM tbl_Materiel WHERE [txtMateriel]='{}'".format(facture.codeCout))
+            materielId = materiel.intlMaterielID if materiel else '859'
+            logger.debug("retrieve intlMaterielID: %s", materielId)
+
+            sql = """INSERT INTO tbl_Facture_Engin ( intlMaterielID, txtMateriel, dblFactureId, strLibelle, dblMontant, strType )
+                    VALUES ({}, '{}', {}, '{}', {}, '{}')
+            """.format(materielId,
+                       facture.codeCout,
+                       facture.factureId,
+                       AnalytiqueDb.nz(facture.libCout),
+                       facture.mntVent,
+                       facture.libRai
+                       )
+            logger.debug("> %s", sql)
+            analytique_db.execute(sql)
+
+        elif facture.codeAxe == "AFFAI":
+            # La ligne concerne une affaire: insère les données dans la table tbl_Facture_Affaire
+            logger.info("> mise à jour de tbl_Facture_Affaire")
+
+            sql = """INSERT INTO tbl_Facture_Affaire ( strAffaireId, dblFactureId, strLibelle, dblMontant, strType )
+                  VALUES ('{}', {}, '{}', {}, '{}')
+                  """.format(facture.codeCout,
+                             facture.factureId,
+                             facture.libRai ,
+                             facture.mntVent,
+                             AnalytiqueDb.nz(facture.libCout),
+                             )
+            logger.debug("> %s", sql)
+            analytique_db.execute(sql)
+
+
+        logger.info("> mise à jour de tbl_Mandatement")
+
+        # Insère les données dans la table tbl_Mandatement
         sql = """INSERT INTO tbl_Mandatement ( dblFacture, strNumMandat, dtmMandat, strBordereau )
               VALUES ({}, '{}', #{}#, '{}')
-              """.format(self.factureId,
-                         self.numMandat,
-                         AnalytiqueDb.format_date(self.dateMandat),
-                         self.numBj
+              """.format(facture.factureId,
+                         facture.numMandat,
+                         AnalytiqueDb.format_date(facture.dateMandat),
+                         facture.numBj
                        )
         logger.debug("> %s", sql)
         analytique_db.execute(sql)
 
-    @staticmethod
-    def load_errfile_data():
-        factures = []
-        try:
-            firstline = True
-            with open(errfile, "r") as f:
-                for line in f:
-                    if firstline:
-                        firstline = False
-                        continue
-                    facture = Facture.from_errfile(line)
-                    factures.append(facture)
-
-        except FileNotFoundError:
-            pass
-        return factures
-
-    @staticmethod
-    def process(factures):
-        analysed, updated, errors = 0, 0, 0
-
-        for facture in factures:
-            analysed += 1
-            try:
-                facture.send_to_db()
-                updated += 1
-            except AlreadyImported:
-                pass
-            except InvalidData:
-                facture.dump_to_err()
-                errors += 1
-        return analysed, updated, errors
-
-
-########
-
-if __name__ == "__main__":
+        # Commit les insertions dans la base
+        analytique_db.commit()
 
-    to_retry = Facture.load_errfile_data()
-    errfile.remove_p()
+        logger.info("Facture %s : ok", facture.factureId)
 
-    if to_retry:
-        logger.info("# Ré-import depuis le fichier d'erreurs")
-        logger.info("{} lignes chargées depuis {}".format(len(to_retry), errfile))
-        res = Facture.process(to_retry)
-        logger.info("> {} lignes traitées / {} importées / {} erreurs".format(res[0], res[1], res[2]))
 
-    else:
-        logger.info("# Import depuis Astre-Gf")
-        res = Facture.process([Facture.from_webservice(wsdata) for wsdata in ws])
-        logger.info("> {} lignes traitées / {} importées / {} erreurs".format(res[0], res[1], res[2]))
 
-    logging.shutdown()
+logging.shutdown()

+ 2 - 2
logging.yaml

@@ -13,7 +13,7 @@ formatters:
 handlers:
     console:
         class: logging.StreamHandler
-        level: DEBUG
+        level: INFO
         formatter: message_only
         stream: ext://sys.stdout
     file:
@@ -30,7 +30,7 @@ handlers:
         formatter: complete
         mailhost: smtp.bas-rhin.fr
         fromaddr: log@bas-rhin.fr
-        toaddrs: [olivier.massot@bas-rhin.fr] #, jacky.klein@bas-rhin.fr
+        toaddrs: [olivier.massot@bas-rhin.fr, jacky.klein@bas-rhin.fr]
         subject: log
         capacity: 100000000