Category: Erp development

(Spanish version) “Retocando” el modulo de activos…

08 Nov 2012

Módulo de Vacaciones OpenERP 6.1

30 Oct 2012

Una vez elegida OpenERP como la herramienta que mejor se adapta a nuestras necesidades, con frecuencia nos encontraremos con la necesidad de adaptar alguno de los módulos disponibles a los requisitos específicos de nuestra empresa.

Haciendo un resumen rápido de lo que tenemos que tener en cuenta antes de elegir qué herramienta se adecua mejor a nuestra empresa y que “verdaderamente” necesitamos para cumplir todos los procesos de nuestro negocio ( esta ultima frase por la que hemos pasado casi de puntillas, quizás sea la fundamental o una de ellas para llevar a cabo una instalación de la herramienta con éxito y sin demasiados quebraderos de cabeza ), tenemos que tener asimilado que los objetivos de cualquier ERP son :

  • Optimización de los procesos empresariales.
  • Acceso a la información.
  • Posibilidad de compartir información entre todos los componentes de la organización.
  • Eliminación de datos y operaciones innecesarias de reingeniería

Además, un ERP como tiene que ser configurable y modular. Una vez elegida OpenERP como la herramienta que mejor se adapta a nuestras necesidades, con frecuencia nos encontraremos con la necesidad de adaptar alguno de los módulos disponibles a los requisitos específicos de nuestra empresa.

Con todo ello, deberemos probar la herramienta para tener claros las siguientes ideas fundamentales:

  • Qué módulos necesitaremos y cuáles no ( y por tanto no deberemos de instalarlos)
  • Realizar o pedir sólo los desarrollos de los módulos que verdaderamente necesitemos. En este apartado tenemos que hacernos a la idea que en muchos casos deberemos adaptar los procesos a la manera-estructura de OpenERP y no a la inversa, porque se trata de “configurar”-“customizar” la herramienta y no “crear” una nueva.

Al hilo de lo escrito, comentaremos el desarrollo de un modulo para gestionar vacaciones a petición de las necesidades de un cliente.

El módulo que desarrollamos fue una extensión al módulo vacaciones “hr_holidays” cuyo autor es OPENERP S.A. , que gestiona las vacaciones de los empleados del sistema. Este modulo basa su comportamiento en la asignación de días de ausencia aprobados por los responsables del departamento de RRHH y que los empleados van gastando en forma de “crédito”, es decir, por cada tipo de ausencia el empleado tiene derecho a un numero determinados de días que se podrá ir incrementando o disminuyendo en función del caso. Aún con la pobre definición de la funcionalidad que acabamos de hacer del módulo, refleja de una manera bastante cercana a como se comporta realmente ya que deja muchas lagunas para una mínima gestión de cualquier departamento de RRHH.

En una empresa pequeña de no mas de 5 empleados podría bastar el módulo para el personal pero a partir de un número mayor ya el gestionar vacaciones con dicho módulo no sería eficiente debido a:

  • No existen calendarios laborales ni festivos, en el momento que dos empleados o mas que puedan pertenecer a lugares de trabajo distinto (con diferente calendario laboral) sería un problema.
  • La gestión de las vacaciones en el cambio de año tampoco existe y corregir de año en año las vacaciones de cada empleado si éste es alto, tambien conllevaria un tiempo ineficiente.
  • En relación con el primer punto sólo existe la posibilidad de contar los días como naturales, dejando atrás la posibilidad que en dos puntos de trabajos distintos se cuentes de una manera u otra (O incluso como hemos desarrollado nosotros, el conteo natural o laboral va asociado al tipo de ausencia y dependiendo de ella, contara dias naturales o laborales).

A parte de esta situación inicial fue básico a la hora de hacer el desarrollo (y en verdad también para cualquier otro) saber manejar bien las fechas en python, concretamente la clase time y datetime y el comportamiento de sus dos metodos mas utlizados : strftime() y strptime(). También para la problemática del conteo de los días hábiles hicimos uso del método isoweekday() de la clase datetime para saber que dia es de la semana dada una fecha..

Con esta problemática, el punto de partida de nuestro desarrollo es el siguiente; creamos dos nuevas identidades calendario_laboral y festivo. Calendario_laboral tendrá una relación one2many con hr_employee y también tendrá una relación many2many con la entidad festivo. Con ello podremos crear múltiples calendarios laborales que podrán poseer los festivos correspondientes, cada año sólo deberemos meter los festivos una vez para luego asociarlos a calendarios sucesivos. A su vez un mismo calendario podré asociarlo también a los empleados que correspondan, así podremos contear las vacaciones en función del calendario que tenga el empleado asignado.

En una primera versión el conteo de los días hábiles o no, lo asociamos a los calendarios laborales pero luego decidimos asociárselo al tipo de ausencia de manera que crearemos un nuevo campo check “dias_habiles” que si esta marcado descontará los días hábiles que hay en el intervalo de tiempo que ha pedido las vacaciones el empleado. En el mismo objeto, crearemos también un checkbox “lanzar_fin” que será el que controle si en el wizard que crearemos para gestionar el cambio de año y la compensación de vacaciones, se creen o no las nuevas asiganaciones de vacaciones para los empleados.

    def dias_habiles(self, date_from, date_to):

        res = 0
        DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
        date_from = datetime.datetime.strptime(date_from, DATETIME_FORMAT)
        date_to = datetime.datetime.strptime(date_to, DATETIME_FORMAT)
        date_from = datetime.datetime.date(date_from)
        date_to = datetime.datetime.date(date_to)
        while( date_from <= date_to):

            if(datetime.datetime.isoweekday(date_from)== 7 or datetime.datetime.isoweekday(date_from)== 6 ):
                res = res + 1
            date_from = date_from + relativedelta(days=+1)
        return res

Y con esto contaremos las vacaciones con el siguiente método:

    def _get_number_of_days(self, cr, uid, id, date_from, date_to, employee_id):
        """Returns a float equals to the timedelta between two dates given as string."""

        DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
        from_dt = datetime.datetime.strptime(date_from, DATETIME_FORMAT)
        to_dt = datetime.datetime.strptime(date_to, DATETIME_FORMAT)
        timedelta = to_dt - from_dt
        diff_day = timedelta.days
        calendario_obj = self.pool.get("hr.holidays.calendario")
        id_cal = calendario_obj.search(cr, uid,[('employee_ids', '=',employee_id)])
        if id_cal:
            calendario = calendario_obj.browse(cr, uid,[id_cal[0]])[0]
            val_intervalo = []
            for festivos in calendario.festivos:
                val_intervalo.append(datetime.datetime.date(datetime.datetime.strptime(festivos.fecha,"%Y-%m-%d")))
            d1 = datetime.datetime.date(from_dt)
            d2 = datetime.datetime.date(to_dt)
            for elem in val_intervalo:
                if elem >= d1 and elem <= d2:
                            diff_day = round(diff_day) - 1
        hol = self.pool.get("hr.holidays.status")
        hol_obj = hol.browse(cr, uid, id)
        if hol_obj.id != False:
            if hol_obj.laborales == True:
                res = self.dias_habiles(date_from, date_to)
                diff_day = diff_day - res

        return diff_day

La idea del asistente de fin de año es regularizar las vacaciones de manera correcta de un año para otro. Para ello una vez que se hubiesen creado las vacaciones de la empresa con sus días correspondientes, si marcamos “lanzar_fin” el asistente creado volverá a reponer los días de disfrute hasta los marcados de nuevo. Resaltar en este apartado, que la única problemática que se nos planteó fue con las Vacaciones, ya que podrían darse 3 casos (supongamos que nuestra empresa tiene derecho a 30 días naturales)

  • Un empleado gasta 30 días —————-> se le asignaran de nuevo 30
  • Un empleado gasta 27 días —————-> se le debe asignar 33
  • Un empleado gasta 34 días —————-> se le debe asignar 26

En un primer acercamiento, se planteó el método de manera que en función de las vacaciones disfrutadas del año anterior se le creaba una nueva asignación con ese número de días. Pero esto solo será válido para el nuevo cambio de año y no en sucesivos, ya que el sistema de esta manera no puede diferenciar si un empleado gasta más o menos vacaciones porque le corresponde (de años anteriores) o porque los ha adelantado, y no actuará en consecuencia. Por tanto el sistema siempre asociará 30 días ( o los correspondientes) y controlará un nuevo tipo de ausencia creada por el modulo, “compensación de vacaciones” de manera que si alguien quiere gastar días de más del año que viene se le deberá asociar a este tipo de ausencia y en consecuencia creará una asignación negativa en los 30 días de vacaciones ( y por tanto se le descontarán las vacaciones anticipadas)

    def regulariza_vacaciones(self,cr,uid,ids,context):

        mes_actual=datetime.datetime.today().strftime("%m")
        dia_actual=datetime.datetime.today().strftime("%d")
        anio_actual=datetime.date.today().strftime("%Y")
        anio_anterior = str(int(anio_actual) - 1)

            # Year change can only be executed first 31 days in January
        if int(mes_actual) == 10 and int(dia_actual)<31:

                holidays = self.pool.get('hr.holidays')
                holidays_obj = self.pool.get('hr.holidays.status')
                holidays_ids = holidays_obj.search(cr, uid,[('lanzar_fin','=',True)])

                category_obj = self.pool.get('hr.employee.category')
                categoria = category_obj.search(cr,uid,[('name','=','Todos')])

                obj_emp = self.pool.get("hr.employee")
                emp_ids = obj_emp.search(cr, uid, [('category_ids',"=" , categoria[0])])

                for hol in holidays_obj.browse(cr, uid, holidays_ids):

                    for emp in emp_ids:

                        resto = holidays._get_remaining_days_by_holidays(cr, uid, emp, hol.id, anio_anterior)
                        if resto < 0:
                            resto = -resto
#                            if resto > hol.ndias:
#                                exc = resto - hol.ndias
#                                resto = hol.ndias - exc
                            if hol.name == "Vacaciones":
                                resto = hol.ndias
                            if hol.name == "Compensacion Vacaciones":
                                resto = -resto
                                hol.name == "Vacaciones"
                                hol.id = holidays_obj.search(cr, uid,[('name','=',"Vacaciones")])[0]

                        values = {
                                  'name' : hol.name,
                                  'holiday_type' : 'employee',
                                  'employee_id': emp,
                                  'type':'add',
                                  'holiday_status_id': hol.id,
                                  'state' : 'validate',
                                  'number_of_days_temp': resto,
                                   }
                        if(resto != 0):
                            holidays.create(cr, uid, values)

        else:
            raise osv.except_osv(_('Warning'),
                            _('La regularizacion de vacaciones en el cambio de año solo se puede realizar en el mes de Enero')) 

        return {}

cambio_anio_wizard()

Ya casi para acabar, deberemos crear las vistas correspondientes para dibujar los menús de la aplicación y colocarlos en los sitios que nos interesen; en nuestro caso “colgaremos” todo desde el menu de configuración de vacaciones, aprovechando el filtrado que trae por defecto el sistema que solo permite acceso a este menú al grupo “HR Manager”.

Para acabar este punto y todo el post, un pequeño apunte en cuanto a filtrados y permisos; el modulo por defecto como vemos a continuación trae los derechos de accesos configurados en el csv correspondiente en la carpeta security, en el que daremos acceso a las nuevas entidades creadas según grupos. Esta tarea en el desarrollo de cualquier modulo podemos encontrar varias veces que “no esta hecha”, ¿ que quiero decir con estas comillas? Que no es que no este hecha, es mas, considero que si no es un desarrollo muy concreto para un determinado proyecto debería de dejarse “a gusto del consumidor” que gestione permisos y accesos a sus necesidades ( una de las esencias del ERP).

"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
"access_calendario_manager","hr.holidays.calendario manager","model_hr_holidays_calendario","base.group_hr_manager",1,1,1,1
"access_festivos_manager","hr.holidays.festivos manager","model_hr_holidays_festivos","base.group_hr_manager",1,1,1,1
"access_calendario_user","hr.holidays.calendario user","model_hr_holidays_calendario","base.group_user",1,0,0,0
"access_festivos_user","hr.holidays.festivos user","model_hr_holidays_festivos","base.group_user",1,0,0,0

Para quien quiera echarle un vistazo mas detenido o hacer uso del modulo que hemos comentado, está disponible para la comunidad en launchpad.

De OrangeHRM a OpenERP con OpenETL

18 Oct 2012

Después de mis últimos post Migración de datos entre distintas instancias de OpenERP usando OpenETL y Carga de datos en OpenERP usando OpenETL y aprovechando que estamos realizando una migración en la empresa desde OrangeHRM a OpenERP que mejor forma de cerrar el círculo que presentar en forma de post el proceso de migración que hemos seguido.

El escenario:

Para migrar los datos hemos partido de los archivos .csv que se generan desde OrangeHRM. Como dichos archivos contienen información de la empresa no los voy a adjuntar con el post, simplemente me limitaré a describir los campos. También partimos de un OpenERP que ya tiene cargados datos como países, provincias, y empresa. Para realizar las llamadas al xmlrpc he usado al usuario admin, el cual ya pertenece a la empresa. De esta forma el campo company_id se ha ajustado automáticamente.

Los archivos .csv contienen los siguientes campos:

Empleados.csv:

  • empID: Identificación del empleado en Orange.
  • lastName: Apellidos del empleado.
  • firstName: Primer nombre del empleado.
  • middleName: Segundo nombre del empleado.
  • street1: Dirección del empleado.
  • street2: Campo de apoyo para street1.
  • city: Municipio.
  • state: Provincia.
  • zip: Código postal.
  • gender: Género.
  • birthDate: Fecha de nacimiento.
  • ssn: Número de la seguridad social.
  • workStation: Departamento al que pertenece el empleado.

Los campos middleName, street1, street2, city, state, zip, workStation,birthDate pueden estar vacíos en el archivo .csv, por lo que hay que controlar estos casos.


dptos.csv:

  • workStation: Nombre del departamento en el sistema.


cargos.csv:

  • empId: Identificación del empleado en Orange.
  • empStatus: Cargo del empleado en la empresa.

El modelo de datos relacionados afectado de OpenERP se presenta a continuación. Lo he simplificado bastante y sólo he puesto los campos de los objetos que se van a cargar con los valores de los .csv.

Si te alejas de la pantalla y te pones bizco, verás a un tio bailando. En realidad es el diagrama de clases simplificado de objetos OpenERP.

Si te alejas de la pantalla y te pones bizco, verás a un tio bailando. En realidad es el diagrama de clases simplificado de objetos OpenERP.

A tener en cuenta:

Observando el modelo de datos se puede ver que muchos objetos están relacionados entre sí. Esto implica que si queremos cargar los empleados, antes tenemos que tener cargados en el sistema las direcciones. Este mismo comportamiento nos sucede con los departamentos, puestos de trabajo, etc.

Para solucionar este inconveniente he usado subtareas de OpenETL. El archivo subjob_example.py contiene un ejemplo para el uso de subtareas con OpenETL. El funcionamiento es bastante sencillo. Simplemente en vez de ejecutar la tarea que queremos convertir en subtarea, crearemos un nuevo componente de tipo subtarea con ella. Después a la tarea padre le pasamos como parámetro dicha subtarea.

En el código:

job_ = openetl.job([csv_in1,datos_ajustados,openobject_out2])  # Para poder relacionar direcciones con personas, las direcciones deben estar cargadas
subjob = openetl.component.transform.subjob(job_)              # en el sistema. Las cargo previamente en una subtarea.

job1=openetl.job([subjob_cargos,subjob_dptos,subjob_paises,subjob,csv_in1,datos_ajustados,openobject_out1])

job1.run()

Las subtareas implicadas son son:

  • subjob_cargos: Carga las categorías de los empleados.
  • subjob_dptos: Carga los departamentos de la empresa.
  • subjo_paises: Realiza correspondencia de países de OpenERP con Orange.
  • subjob: Carga las direcciones de los empleados.

Y los diagramas de cada subtarea:

Diagramas de subjob_cargos y subjob_dptos.

Diagramas de subjob_cargos y subjob_dptos.

Diagramas de subjob_paises y subjob.

Diagramas de subjob_paises y subjob.

Para relacionar los objetos de las subtareas con la carga final, la de los empleados, he usado un pequeño truco. Vamos a fijarnos en la lista de categorías de empleados (subjob_cargos).

Al leer las categorías desde el csv inicial, las he pasado por una transformación que ejecuta una función (preprocess_cargos):

lista_cargos = {}
def preprocess_cargos(self, channels):
    for trans in channels['carga_cargos']:
        for d in trans:
            lista_cargos[d['empId']] = d['empStatus']
    return None

pres_cargos=openetl.component.transform.map({},preprocess_cargos)

Dicha función lo único que hace es cargar en un diccionario una relación empId-empStatus, es decir, relaciona id de empleado con su categoría.

Más adelante en el código, al realizar la carga de los empleados, consulto dicho diccionario:

def preprocess(self, channels):
    cdict = {}
    for trans in channels['modificacion']:
        for d in trans:
            .
            .
            .
            # Ajuste de cargo
            d['cargo'] = lista_cargos[d['empId']]

    return {'resultado':cdict}

Y por último en el mapeado del objeto, antes de cargarlo en OpenERP:

openobject_out1 = openetl.component.output.openobject_out(
     ooconnector,
     'hr.employee',
     {
      .
      .
      .
      'job_id':'cargo',
      }
    )

En el diagrama también aparece una paso previo por el componente unique. Dicho componente quita los elementos duplicados antes de cargarlos en el sistema. Hay un ejemplo de uso de dicho componente en el fichero unique_csv_data.py.

Otra cosa interesante de esta migración es como se han mapeado los datos del csv a los objetos. El fichero join_example.py contiene un ejemplo que usa map_keys. Dicho ejemplo está muy bien y funciona siempre y cuando se usen componentes “openetl.component.input.data” definidos en el propio archivo de script. El problema es que cuando se lee un archivo de csv no se está usando una entrada “estática”, sino secuencial. De modo que el map_keys es ignorado. La solución en este caso ha sido pasar por parámetro un map_key vacío y realizar el mapeo de datos desde el propio código de la función preprocess.

En el caso de países:

pre_paises=openetl.component.transform.map({},preprocess_paises)
def preprocess(self, channels):
    cdict = {}
    for trans in channels['modificacion']:
        for d in trans:

           .
           .
           .
            # Ajuste de paises
            if d['state'] == "Santo Domingo":
                d['state'] = lista_paises[62]  # Codigo de Republica Dominicana
            elif d['state'] == "Distrito Nacional":
                d['state'] = lista_paises[62]
            else:
                d['state'] = lista_paises[69] # Codigo de Espagna
            .
            .
            .    

    return {'resultado':cdict}

El resultado:

Como en mis anteriores post presento el código completo de la solución obtenida. Evidentemente este script de migración cubre nuestras necesidades concretas, pero es fácil adaptarlo si se necesitan migrar datos diferentes. También se podría haber realizado un script de migración atacando directamente a la base de datos de Orange, aunque por seguir con el ejemplo planteado en el primer post de OpenETL se han usado archivos .csv. En cualquier caso OpenETL también contiene conectores para consultas SQL. El archivo sql_in_example.py contiene un ejemplo con el que se podrían sustituir las llamadas a los csv con consultas sql.

import sys
sys.path.append('..')

import openetl

#===============================================================================
# Conectores
#===============================================================================
fileconnector_orange=openetl.connector.localfile('/home/carlos/Escritorio/Orange/Empleados.csv')
fileconnector_orange_dptos=openetl.connector.localfile('/home/carlos/Escritorio/Orange/dptos.csv') # Con tratamiento previo
fileconnector_orange_cargos=openetl.connector.localfile('/home/carlos/Escritorio/Orange/cargos.csv') # Con tratamiento previo
ooconnector = openetl.connector.openobject_connector('http://localhost:8069', 'master_viavansi', 'admin', 'admin', con_type='xmlrpc')

#===============================================================================
# Componentes
#===============================================================================
csv_in1= openetl.component.input.csv_in(fileconnector_orange,name='Datos de Orange')
csv_in_dptos= openetl.component.input.csv_in(fileconnector_orange_dptos,name='Departamentos')
csv_in_cargos= openetl.component.input.csv_in(fileconnector_orange_cargos,name='Cargos')

openobject_out1 = openetl.component.output.openobject_out(
     ooconnector,
     'hr.employee',
     {
      'name':'name_csv',
      'ssnid':'ssn',
      'gender':'gender',
      'birthday':'birthDate',
      'address_home_id':'name_csv', # Nombre de la relacion
      'department_id':'workStation',
      'job_id':'cargo',
      }
    )

openobject_out2 = openetl.component.output.openobject_out(
     ooconnector,
     'res.partner.address',
     {
      'name':'name_csv',
      'street':'street1',
      'street2':'street2',
      'zip':'zip',
      'city':'city',
      'country_id':'state',
      }
    )

openobject_out3 = openetl.component.output.openobject_out(
     ooconnector,
     'hr.department',
     {
      'name':'workStation',
      }
    )

# Soporte para carga de datos de cargo de empleado. El Diccionario se carga en subtarea previa
lista_cargos = {}
openobject_out4 = openetl.component.output.openobject_out(
     ooconnector,
     'hr.job',
     {
      'name':'empStatus',
      }
    )

def preprocess_cargos(self, channels):
    for trans in channels['carga_cargos']:
        for d in trans:
            lista_cargos[d['empId']] = d['empStatus']
    return None

pres_cargos=openetl.component.transform.map({},preprocess_cargos)

# Soporte para carga de datos de paises. El Diccionario se carga en subtarea previa
lista_paises = {}

openobject_in1 = openetl.component.input.openobject_in(
                 ooconnector,'res.country',
                 fields=['id','name'],
                 )

def preprocess_paises(self, channels):
    for trans in channels['carga_paises']:
        for d in trans:
            lista_paises[d['id']] = d['name']
    return None

pre_paises=openetl.component.transform.map({},preprocess_paises)

# Soporte transformaciones y componentes

def preprocess(self, channels):
    cdict = {}
    for trans in channels['modificacion']:
        for d in trans:
            # name: no existia,lo creo yo con la suma de los campos 

            if d['middleName'] == "":  # En OpenERP, no se separan los campos, hay un unico campo name
                d["name_csv"] = d["firstName"] + str(" ")+ d["lastName"]
            else:
                d["name_csv"] = d["firstName"] + str(" ")+ d["middleName"] +str(" ")+ d["lastName"]

            if d['gender'] == "M":     # Adaptacion de nomencaltura de datos de Orange a OpenERP
                d['gender'] = 'male'
            else:
                d['gender'] ='female'

            # Ajuste de paises
            if d['state'] == "Santo Domingo":
                d['state'] = lista_paises[62]  # Codigo de Republica Dominicana
            elif d['state'] == "Distrito Nacional":
                d['state'] = lista_paises[62]
            else:
                d['state'] = lista_paises[69] # Codigo de Espagna

            # Ajuste de cargo
            d['cargo'] = lista_cargos[d['empId']]

    return {'resultado':cdict}            

datos_ajustados=openetl.component.transform.map({},preprocess)  # Como leo un flujo de datos, no hay key_map. key_maps es para diccionarios

#===============================================================================
# Transiciones, Definicion de trabajo y ejecucion. Operaciones de Carga
#===============================================================================

log_cargos=openetl.component.transform.logger(name='Log de cargos')
unique_job = openetl.component.transform.unique()
openetl.transition(csv_in_cargos,pres_cargos,channel_destination='carga_cargos')
openetl.transition(pres_cargos,log_cargos)
openetl.transition(csv_in_cargos,unique_job)
openetl.transition(unique_job,openobject_out4)
job_cargos=openetl.job([csv_in_cargos,unique_job,openobject_out4,log_cargos])
subjob_cargos = openetl.component.transform.subjob(job_cargos)  

unique = openetl.component.transform.unique()
log_dptos=openetl.component.transform.logger(name='Log departamentos')

openetl.transition(csv_in_dptos,unique)
openetl.transition(unique,log_dptos,channel_source='main')
openetl.transition(unique,openobject_out3)
job_dptos=openetl.job([log_dptos,openobject_out3])
subjob_dptos = openetl.component.transform.subjob(job_dptos)  

openetl.transition(openobject_in1,pre_paises, channel_destination='carga_paises')
job_paises = openetl.job([openobject_in1,pre_paises])
subjob_paises = openetl.component.transform.subjob(job_paises)  

openetl.transition(csv_in1,datos_ajustados, channel_destination='modificacion') # Leo datos aplicando preprocesamiento
openetl.transition(csv_in1,openobject_out2) # Direcciones
openetl.transition(csv_in1,openobject_out1) # Personas

job_ = openetl.job([csv_in1,datos_ajustados,openobject_out2])  # Para poder relacionar direcciones con personas, las direcciones deben estar cargadas
subjob = openetl.component.transform.subjob(job_)              # en el sistema. Las cargo previamente en una subtarea.

job1=openetl.job([subjob_cargos,subjob_dptos,subjob_paises,subjob,csv_in1,datos_ajustados,openobject_out1])
job1.run()

Con esto concluye la parte técnica del post. Creo que OpenETL es una tecnología muy interesante, que permite realizar trabajos de ETL de forma bastante cómoda e intuitiva. También os comento que he echado en falta algo más de documentación técnica sobre OpenETL, ya que he tenido que recurrir al código fuente de muchos componentes, transformaciones, etc. para averiguar que es lo que hacían.

A pesar de ello la línea de aprendizaje de esta tecnología es bastante sencilla una vez que sabes que hay que hacer, y se pueden lograr grandes cosas en poco tiempo.

Para finalizar os comentaré que mi impresión final sobre OpenETL es muy buena. No sólo porque se adapte perfectamente a operaciones ETL sobre OpenERP, sino porque tiene un amplio abanico de conectores (sql, facebook, xmlrpc,csv, gdoc, gcalendar, etc) que permiten usar OpenETL en muchos proyectos con distintas tecnologías.

Migración de datos entre diferentes instancias de OpenERP usando OpenETL

25 Sep 2012

En mi anterior post realicé una introducción a OpenETL. También desarrollé un ejemplo de carga de datos desde una archivo .csv a OpenERP.

En este post voy a profundizar un poco más, realizando una migración de datos de OpenERP a OpenERP en los cuales hay tablas relacionadas.

El escenario

Nuestro entorno de migración constará de las siguientes características:

  • BD_inicial contiene los datos que queremos migrar. Los datos serán los clientes y proveedores con sus direcciones.
  • No todos los clientes o proveedores tienen una dirección asociada, por lo que hay que controlar la excepción.
  • Para simplificar el ejemplo voy a migrar sólo el contenido de los campos name, title y partner_id, siendo title el campo que contiene la relación con la tabla res_partner_title y partner_id el campo relacionado con la tabla res_partner.
Diagrama de relación de clases simplificado de res_partner_address con res_partner y res_partner_titulo

Relación simplificada de relaciones de objetos res_partner_address, res_partner_title y res_partner.

A tener en cuenta

Cuando migras un contenido desde OpenERP a un archivo .csv el sistema suele funcionar sin complicaciones. Sin embargo cuando se realiza la migración de OpenERP a OpenERP es fácil obtener excepciones tal como

File "/usr/lib/python2.6/xmlrpclib.py", line 838, in close
raise Fault(**self._stack[0])
xmlrpclib.Fault: <Fault 'bool' object has no attribute 'lower': 'Traceback (most recent call last):n  File … … … …  /openerp/osv/orm.py", line 1380, in process_linessn    res = line[i].lower() not in ('0', 'false', 'off')nAttributeError: 'bool' object has no attribute 'lower'n'>

Esto ocurre porque OpenERP al leer un campo sin valor le asigna por defecto un booleano inicializado a False.

En el ejemplo data_map.py después de leer los valores de ejemplo desde el .csv el autor realiza una transformación de los mismos antes de mostrarlos en el log. Basándose en ese ejemplo, es fácil inicializar los campos con valores adecuados:

def preprocess(self, channels):
    cdict = {}
    for trans in channels['modificacion']:
        for d in trans:
            if d["name"] == False:
                d["name"] = ""
            cdict[d['id']] = d
    return {'resultado':cdict}

Otra cosa que también puede producir muchos quebraderos de cabeza es que en los campos relacionados no se va a poner el id de la tupla relacionada, sino el valor de la misma. Nuestra función quedaría así:

def preprocess(self, channels):
    cdict = {}
    for trans in channels['modificacion']:
        for d in trans:
            if d["title"] == False: # Es una relacion, ej: 'title': [5, 'Sir'] , con res_partner_title
                d["title"] = '' # Si quiero dejarlo sin valor, le dejo las comillas
            else:
                d["title"] = d["title"][1]  # No se coge el 0, que es el id, sino el valor. El id se ajusta automatico :/
            if d["name"] == False:
                d["name"] = ""

            if d["partner_id"] == False:
                d["partner_id"] = ""
            else:
                d["partner_id"] = d["partner_id"][1]

            cdict[d['id']] = d
    return {'resultado':cdict}

El resultado

El código completo, con conectores, componentes, transiciones, etc. se muestra a continuación. Nótese que la función de procesamiento es llamada desde una transición openetl.component.transform.map(map_keys,preprocess), en la que se pasa también un parámetro map. Hay más ejemplos parecidos en data_map.py y m2m_into_oo.py.

#!/usr/bin/python

import sys
sys.path.append('..')

import openetl
from openetl import transformer

# Conectores
ooconnector_in = openetl.connector.openobject_connector('http://localhost:8069', 'BD_inicial', 'admin', 'admin', con_type='xmlrpc')
ooconnector_out = openetl.connector.openobject_connector('http://localhost:8069', 'BD_final', 'admin', 'admin', con_type='xmlrpc')

# Componentes
openobject_in1 = openetl.component.input.openobject_in(
                 ooconnector_in,'res.partner.address',
                 fields=['id','title','name','partner_id'],
                 )

openobject_in2 = openetl.component.input.openobject_in(
                 ooconnector_in,'res.partner',
                 fields=['id','name'],
                 )

openobject_out1 = openetl.component.output.openobject_out(
     ooconnector_out,
     'res.partner.address',
     {'name':'name','title':'title','partner_id':'partner_id'}
    )

openobject_out2 = openetl.component.output.openobject_out(
     ooconnector_out,
     'res.partner',
     {'name':'name'}
    )

log=openetl.component.transform.logger(name='Recien leido:Read Partner File ')

# Soporte transformaciones

map_keys = {'main': {
    'name': "resultado[main['id']]['name']",
    'title': "resultado[main['id']]['title']",
    'partner_id': "resultado[main['id']]['partner_id']",
}}

def preprocess(self, channels):
    cdict = {}
    for trans in channels['modificacion']:
        for d in trans:
            if d["title"] == False: # Es una relacion, ej: 'title': [5, 'Sir'] , con res_partner_title
                d["title"] = '' # Si quiero dejarlo sin valor, le dejo las comillas
            else:
                d["title"] = d["title"][1]  # No se coge el 0, que es el id, sino el valor. El id se ajusta automatico :/
            if d["name"] == False:
                d["name"] = ""

            if d["partner_id"] == False:
                d["partner_id"] = ""
            else:
                d["partner_id"] = d["partner_id"][1]

            cdict[d['id']] = d
    return {'resultado':cdict}               

map=openetl.component.transform.map(map_keys,preprocess)

# Transiciones
tran1=openetl.transition(openobject_in1,map, channel_destination='modificacion')
tran3=openetl.transition(openobject_in1,log)

tran_res_partner01=openetl.transition(openobject_in2, openobject_out2)

tran4=openetl.transition(openobject_in1, map)
tran4=openetl.transition(map, openobject_out1)

# Definicion de trabajo y ejecucion
job1=openetl.job([openobject_in1,map,openobject_out1,openobject_in2,openobject_out2])
job1.run()

Este código funciona y realiza la migración de datos sin ningún problema siempre que en las tablas relacionadas no haya ningún dato con igual campo “valor relacionado” repetido. ¿Y qué pasa si el “valor relacionado” sí está repetido? Lo que ocurre en este caso es que el sistema creará la relación con la tupla con id más pequeño. Para corregir esta situación bastaría con añadir alguna condición más a la función preprocess, ayudarnos de alguna otra función en python auxiliar, etc. Si se diera ese caso los ejemplos sql_in_example.py, csv_diff_example.py, join_example.py, podrían servir como base en función del tratamiento que quisiéramos hacer.

OpenERP data load using OpenETL

08 Ago 2012

Introduction:

OpenETL is a library in python for OpenERP S.A Data Migration. This tool allows us to perform all typical ETL operations (extract, transform and load) with the added value of being very well integrated with OpenERP.

The website of the library is https://launchpad.net/openetl

There is also a graphical interface packaged as a module (etl_interface) for OpenERP. This module is an extra add-ons. Although since the interface is easier to handle, this post will focus on the use of python library.

Installing:

To download the library you must install bazaar.

The specific command:

bzr branch lp:openetl

After downloading the openerp branch, copy the folder openetl/lib/openetl to your system libraries folder. In my case /usr/lib/python2.6/.

Although in my eclipse with PyDev I already have set the route, Eclipse Indigo seems not to “catch” the first library. To refresh the accessible libraries in eclipse, go to PyDevInterpreter – Python, click on Restore Defaults (if we already had configured the system) and then click on Apply.

Performance:

The library is divided into work, components, connectors, and transitions.

  • Jobs: Processes that can run, pause and stop.
  • Components: Inputs, outputs and transformation components. They allow us to get data and store them into external systems.The transformation components will be those that fit the data before the final charge.
  • Connectors: The connectors define connections with the external systems. They are used by the components. The current version of the library has connectors to treat local files, Openobjects, different databases (postgres, mysql, oracle), URLs (http, ftp, https, gopher), xmlrpc web services , SugarCRM, other google services (GDocs , GCalendar, gblog) and facebook.
  • Transitions:  Transitions are the flow the data between component is passing through.

The programmer must define as many input conectors as outputs are needed, at least one component for each connector, and a minimal transition to pass data from one component to another. Connectors are linked with the components, so for writting data into the component, they are written into the external system.

Instance:

We have a OrangeHRM system installed in the company with the HR tabs we want to migrate to OpenERP. The HR staff has exported the data to a .Csv format and asks us to perform the OpenERP data loading.

What we have to do is create two connectors, one will connect locally against the .csv file and the other type of XMLRPC will connect to OpenERP. Then define the components that are going to store the information, create the transitions (one for writing data to the final component and one for sorting the data) which are going to run and finally we launch the task. In the OpenObject component, we’ll define the csv fields mapping to the erp object fields . Transitions are executed sequentially as you have defined them in the .py .

Diagrama de carga de datos con OpenETL con 2 transiciones y 2 conectores

Diagrama de carga de datos con OpenETL con 2 transiciones y 2 conectores

The associated source code:

import sys
sys.path.append('..')

import openetl

# Conectors
fileconnector_orange=openetl.connector.localfile('/home/carlos/Escritorio/csvRaquel/DatosRRHHOrangeHRM.csv')

ooconnector = openetl.connector.openobject_connector('http://localhost:8069', 'testProject', 'admin', 'admin', con_type='xmlrpc')

# Components
csv_in1= openetl.component.input.csv_in(fileconnector_orange,name='Datos de Orange')

oo_out_employee = openetl.component.output.openobject_out(
     ooconnector,
     'hr.employee',
     {'name':'firstName'}
    )

sort1=openetl.component.transform.sort('firstName')

# Transitions

tran1=openetl.transition(csv_in1,sort1)

tran2=openetl.transition(sort1, oo_out_employee)

# Work and run definition
job1=openetl.job([csv_in1,sort1,oo_out_employee])
job1.run()

To make the example easier, I have simplified the number of fields to load from the .csv, fields with related tables, etc.. But the load can be as much complicated as necessary so that all data is migrated correctly.

In the folder openetlexamples there are examples of all the things we will need in the migrating data process , going from examples with multiple inputs and outputs (csv_in_out.py) to data load with related tables (m2m_into_oo.py). This example is quite interesting, as it reads users and csv files groups and load them directly into OpenERP. Let’s note also the example of migrating data from SugarCRM, facebook, GCalendar, etc..

Processes that can run, pause and stop.