diff --git a/pyjeeves/connector.py b/pyjeeves/connector.py index 0eba94a..bb62f48 100644 --- a/pyjeeves/connector.py +++ b/pyjeeves/connector.py @@ -6,29 +6,31 @@ Global objects """ from pyjeeves import logging, config -from weakref import WeakValueDictionary -from sqlalchemy import create_engine, orm -from sqlalchemy.orm import sessionmaker, scoped_session, Query, aliased -from sqlalchemy.orm.exc import UnmappedClassError -from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session +# from sqlalchemy.orm.exc import UnmappedClassError +from pymssql import OperationalError + +from sqlservice import SQLClient, SQLQuery logger = logging.getLogger("PyJeeves." + __name__) -class BaseFilterQuery(Query): +class BaseFilterQuery(SQLQuery): def get(self, ident): # Override get() so that the flag is always checked in the # DB as opposed to pulling from the identity map. - this is optional. - return Query.get(self.populate_existing(), ident) + return SQLQuery.get(self.populate_existing(), ident) def __iter__(self): - return Query.__iter__(self.private()) + return SQLQuery.__iter__(self.private()) def from_self(self, *ent): # Override from_self() to automatically apply # the criterion to. this works with count() and # others. - return Query.from_self(self.private(), *ent) + return SQLQuery.from_self(self.private(), *ent) def private(self): # Fetch the model name and column list and apply model-specific base filters @@ -49,94 +51,58 @@ class BaseFilterQuery(Query): return self -class Model(object): - """Baseclass for custom user models.""" - - #: the query class used. The :attr:`query` attribute is an instance - #: of this class. By default a :class:`BaseQuery` is used. - query_class = BaseFilterQuery - - #: an instance of :attr:`query_class`. Can be used to query the - #: database for instances of this model. - query = None - - -class MetaBaseModel(DeclarativeMeta): - """ Define a metaclass for the BaseModel - Implement `__getitem__` for managing aliases """ - - def __init__(cls, *args): - super().__init__(*args) - cls.aliases = WeakValueDictionary() - - def __getitem__(cls, key): - try: - alias = cls.aliases[key] - except KeyError: - alias = aliased(cls) - cls.aliases[key] = alias - return alias - - -class _QueryProperty(object): - - def __init__(self, sa): - self.sa = sa - - def __get__(self, obj, type): - try: - mapper = orm.class_mapper(type) - if mapper: - if type.__module__ == 'pyjeeves.models.raw': - return type.query_class(mapper, session=self.sa.raw_session()) - else: - return type.query_class(mapper, session=self.sa.meta_session()) - except UnmappedClassError: - return None - - class DBConnector(object): """This class is used to control the SQLAlchemy integration""" - def __init__(self, enabled_sessions=['raw'], metadata=None): + + def __init__(self, enabled_clients=['raw'], metadata=None): logger.info("Creating engines and sessionmakers") - self.raw_session, self.meta_session = self.create_scoped_session(enabled_sessions) - self.Model = self.make_declarative_base(metadata) - # self.Query = Query + self.raw, self.raw_engine = (self.raw_session() if 'raw' in enabled_clients else {}) + self.meta = (self.meta_session() if 'meta' in enabled_clients else {}) - @property - def metadata(self): - """Returns the metadata""" - return self.Model.metadata + def callproc(self, procedure="", params=[]): + conn = self.raw_engine.raw_connection() - # @property - # def _config(self): - # """Returns the configuration""" - # return config() + with conn.cursor() as cursor: + try: + retval = cursor.callproc(procedure, params) + try: + cursor.nextset() + retval = cursor.fetchall() + except OperationalError: + logger.debug("Executed statement has no resultset") - def make_declarative_base(self, metadata=None): - """Creates the declarative base.""" - base = declarative_base(cls=Model, name='Model', - metadata=metadata, - metaclass=MetaBaseModel) - base.query = _QueryProperty(self) - return base + conn.commit() - def create_scoped_session(self, sessions=[]): - RawSession, MetaSession = None, None - if 'raw' in sessions: - raw_engine = create_engine( - 'mssql+pymssql://{user}:{pw}@{host}:{port}/{db}?charset=utf8'.format( - **config.config['databases']['raw']), - implicit_returning=False) + finally: + conn.close() - RawSession = scoped_session(sessionmaker(bind=raw_engine)) + return retval - if 'meta' in sessions: - meta_engine = create_engine( - 'mysql+pymysql://{user}:{pw}@{host}:{port}/{db}?charset=utf8mb4'.format( - **config.config['databases']['meta'])) + def execute(self, operation=""): + conn = self.raw_engine.raw_connection() - MetaSession = scoped_session(sessionmaker(bind=meta_engine)) + with conn.cursor(as_dict=True) as cursor: + try: + cursor.execute(operation) + results = cursor.fetchall() + finally: + conn.close() + return results - return RawSession, MetaSession + def raw_session(self): + + uri = 'mssql+pymssql://{user}:{pw}@{host}:{port}/{db}?charset=utf8'.format( + **config.config['databases']['raw']) + sql_client_config = {'SQL_DATABASE_URI': uri} + db = SQLClient(sql_client_config, query_class=BaseFilterQuery) + + return db.session, db.engine + + def meta_session(self): + + meta_engine = create_engine( + 'mysql+pymysql://{user}:{pw}@{host}:{port}/{db}?charset=utf8mb4'.format( + **config.config['databases']['meta'])) + + return scoped_session(sessionmaker(bind=meta_engine)) diff --git a/pyjeeves/models/abc.py b/pyjeeves/models/abc.py index 9130c30..592cc5c 100644 --- a/pyjeeves/models/abc.py +++ b/pyjeeves/models/abc.py @@ -2,11 +2,17 @@ Define an Abstract Base Class (ABC) for models """ from decimal import Decimal +from datetime import datetime from sqlalchemy.sql.expression import and_ from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.exc import OperationalError -from sqlservice import ModelBase +from sqlalchemy.schema import MetaData, Column +from sqlalchemy.types import Integer +from sqlalchemy.orm.collections import InstrumentedList + +from sqlservice import ModelBase, as_declarative from pyjeeves import logging @@ -14,7 +20,19 @@ from . import db logger = logging.getLogger("PyJeeves." + __name__) +logger.info("Reading Jeeves DB structure") +meta = MetaData() +try: + meta.reflect(bind=db.raw.connection(), + only=['ar', 'ars', 'xae', 'xare', 'fr', 'kus', 'x1k', + 'oh', 'orp', 'lp', 'vg', 'xp', 'xm', 'prh', 'prl']) +except OperationalError as e: + logger.error("Failed to read Jeeves DB structure") + raise e + + +@as_declarative(metadata=meta) class RawBaseModel(ModelBase): """ Generalize __init__, __repr__ and to_json Based on the models columns , ForetagKod=1""" @@ -22,6 +40,7 @@ class RawBaseModel(ModelBase): __to_dict_filter__ = [] __to_dict_only__ = () __column_map__ = {} + __reversed_column_map__ = lambda self: {v: k for k, v in self.__column_map__.items()} # noqa __table_args__ = { 'extend_existing': True @@ -29,11 +48,19 @@ class RawBaseModel(ModelBase): __dict_args__ = { 'adapters': { - # datetime: lambda value, col, *_: value.strftime('%Y-%m-%d'), - Decimal: lambda value, col, *_: "{:.2f}".format(value) + datetime: lambda value, col, *_: value.strftime('%Y-%m-%d %H:%M'), + Decimal: lambda value, col, *_: float(value) # "{:.2f}".format(value) } } + ForetagKod = Column(Integer, primary_key=True) + + def __init__(self, data=None, **kargs): + if data: + data = self._map_keys(data) + self.update(data, **kargs) + # super(RawBaseModel, self).__init__(data=None, **kargs) + @classmethod def _base_filters(self, obj, filters=and_()): # This method provides base filtering, additional filtering can be done in subclasses @@ -50,6 +77,44 @@ class RawBaseModel(ModelBase): return self.__column_map__[key] return key + def _map_keys(self, data={}): + rv = {} + for key, value in self.__reversed_column_map__().items(): + if key in data: + rv[value] = data[key] + for key, value in data.items(): + if hasattr(self, key): + if key in self.relationships().keys(): + rv[key] = self._map_relationship_keys(key, value) + else: + rv[key] = value + return rv + + def _map_relationship_keys(self, field, value): + """Get model relationships fields value. Almost a copy from SQLService ModelBase""" + relation_attr = getattr(self.__class__, field) + uselist = relation_attr.property.uselist + relation_class = relation_attr.property.mapper.class_ + + if uselist: + if not isinstance(value, (list, tuple)): # pragma: no cover + value = [value] + + # Convert each value instance to relationship class. + value = [relation_class(val) if not isinstance(val, relation_class) + else val + for val in value] + elif value and isinstance(value, dict): + # Convert single value object to relationship class. + value = relation_class(value) + elif not value and isinstance(value, dict): + # If value is {} and we're trying to update a relationship + # attribute, then we need to set to None to nullify relationship + # value. + value = None + + return value + def descriptors_to_dict(self): """Return a ``dict`` that maps data loaded in :attr:`__dict__` to this model's descriptors. The data contained in :attr:`__dict__` represents @@ -90,6 +155,18 @@ class RawBaseModel(ModelBase): return rv + def from_dict(self, data={}): + for key, value in self.__reversed_column_map__().items(): + if key in data: + self[value] = data[key] + for key, value in data.items(): + if hasattr(self, key): + if isinstance(self[key], InstrumentedList): + pass + else: + self[key] = value + return self + def merge(self): db.raw_session.merge(self) return self diff --git a/pyjeeves/models/raw.py b/pyjeeves/models/raw.py index cdac9ed..c99cfb7 100644 --- a/pyjeeves/models/raw.py +++ b/pyjeeves/models/raw.py @@ -5,50 +5,38 @@ Jeeves raw data models """ -# from sqlalchemy import Column, String -from sqlalchemy.schema import MetaData, ForeignKey, Column + +from sqlalchemy.schema import ForeignKey, Column from sqlalchemy.orm import relationship from sqlalchemy.types import Integer, String -from sqlalchemy.ext.automap import automap_base + from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.sql.expression import and_ -from sqlalchemy.exc import OperationalError - -from . import db from pyjeeves import logging from .abc import RawBaseModel +from .sp_classes import OrderHead, OrderRow, PlaceOrder + +import re logger = logging.getLogger("PyJeeves." + __name__) -logger.info("Reading Jeeves DB structure") - -meta = MetaData() -try: - meta.reflect(bind=db.raw_session.connection(), - only=['ar', 'ars', 'xae', 'xare', 'fr', 'kus', 'x1k', - 'oh', 'lp', 'vg', 'xp', 'xm', 'prh', 'prl']) -except OperationalError as e: - logger.error("Failed to read Jeeves DB structure") - raise e - -Base = automap_base(cls=db.Model, name='Model', metadata=meta) -class ProductClass(Base, RawBaseModel): +class ProductClass(RawBaseModel): __tablename__ = 'xp' __column_map__ = {'ArtProdKlass': 'ProductClassNumber', 'ArtProdklBeskr': 'ProductClassName'} __to_dict_only__ = ('ArtProdKlass', 'ArtProdklBeskr') # print_filter = ('Articles', 'articles_collection') -class ArticleClass(Base, RawBaseModel): +class ArticleClass(RawBaseModel): __tablename__ = 'xm' __column_map__ = {'ArtKod': 'ArticleClassNumber', 'ArtTypBeskr': 'ArticleClassName'} __to_dict_only__ = ('ArtKod', 'ArtTypBeskr') # print_filter = ('Articles', 'articles_collection') -class CommodityGroup(Base, RawBaseModel): +class CommodityGroup(RawBaseModel): __tablename__ = 'vg' __column_map__ = {'VaruGruppKod': 'CommodityGroupNumber', 'VaruGruppBeskr': 'CommodityGroupName'} @@ -61,14 +49,14 @@ class CommodityGroup(Base, RawBaseModel): ArticleClass = relationship(ArticleClass) -class ArticleAlternativeUnit(Base, RawBaseModel): +class ArticleAlternativeUnit(RawBaseModel): __tablename__ = 'xae' __column_map__ = {'AltEnhetKod': 'UnitCode', 'AltEnhetBeskr': 'UnitName', 'AltEnhetOmrFaktor': 'DefaultUnitConv'} __to_dict_only__ = ('AltEnhetBeskr', 'AltEnhetOmrFaktor') -class ArticleUnit(Base, RawBaseModel): +class ArticleUnit(RawBaseModel): __tablename__ = 'xare' __column_map__ = {'ArtNr': 'ArticleNumber', 'AltEnhetKod': 'UnitCode', 'AltEnhetOmrFaktor': 'UnitConv', @@ -82,7 +70,7 @@ class ArticleUnit(Base, RawBaseModel): ArticleAlternativeUnit = relationship(ArticleAlternativeUnit) -class ArticleBalance(Base, RawBaseModel): +class ArticleBalance(RawBaseModel): __tablename__ = 'ars' __column_map__ = {'LagSaldo': 'Balance', 'LagResAnt': 'ReservedBalance', @@ -100,7 +88,7 @@ class ArticleBalance(Base, RawBaseModel): ArtNr = Column(Integer, ForeignKey('ar.ArtNr'), primary_key=True) -class Article(Base, RawBaseModel): +class Article(RawBaseModel): __tablename__ = 'ar' __column_map__ = {'ArtNr': 'ArticleNumber', @@ -178,7 +166,7 @@ class Article(Base, RawBaseModel): ) -class Company(Base, RawBaseModel): +class Company(RawBaseModel): __tablename__ = 'fr' __column_map__ = {'FtgNr': 'CompanyNumber', 'FtgNamn': 'CompanyName'} __to_dict_only__ = ('FtgNr', 'FtgNamn', 'Customer') @@ -188,13 +176,17 @@ class Company(Base, RawBaseModel): Customer = relationship('Customer', uselist=False, back_populates='Company', lazy='joined') -class CustomerCategory(Base, RawBaseModel): +class DelivLoc(RawBaseModel): + __tablename__ = 'lp' + + +class CustomerCategory(RawBaseModel): __tablename__ = 'x1k' KundKategoriKod = Column(Integer, primary_key=True) -class Customer(Base, RawBaseModel): +class Customer(RawBaseModel): __tablename__ = 'kus' __column_map__ = {'FtgNr': 'CompanyNumber', 'kundkategorikod': 'CustomerCategoryCode', 'PrisListaKundSpec': 'PriceListPrimary', 'PrisLista': 'PriceListSecondary'} @@ -218,10 +210,10 @@ class Customer(Base, RawBaseModel): @hybrid_property def CustomerCategory(self): - return self.KundKategori.KundKatBeskr + return self.KundKategori.KundKatBeskr if self.KundKategori else "" -class PriceList(Base, RawBaseModel): +class PriceList(RawBaseModel): __tablename__ = 'prh' __column_map__ = {'PrisListaBeskr': 'Description', 'PrisLista': 'PriceListNumber', 'MarkUpBelopp': 'PriceFactor'} @@ -234,7 +226,7 @@ class PriceList(Base, RawBaseModel): PriceListItems = relationship('PriceListItem', back_populates="PriceList", lazy='joined') -class PriceListItem(Base, RawBaseModel): +class PriceListItem(RawBaseModel): __tablename__ = 'prl' __column_map__ = {'ArtNr': 'ArticleNumber', 'vb_pris': 'UnitPrice', 'MarkUpBelopp': 'UnitPriceFactor', 'NollFaktor': 'NullPriceAllowed'} @@ -272,8 +264,123 @@ class PriceListItem(Base, RawBaseModel): self.Article.get_unit_conv()) -Base.prepare() +class Order(RawBaseModel): + __tablename__ = 'oh' + __column_map__ = {'OrderNr': 'OrderNumber', 'FtgNr': 'CompanyNumber', + 'OrdDatum': 'OrderDate', 'OrdStat': 'OrderStatusCode', + 'OrdLevAdr1': 'AddrName', 'OrdLevAdr2': 'AddrCO', + 'OrdLevAdr3': 'AddrStreet', 'OrdLevAdrLandsKod': 'AddrCountry', + 'KundBestNr': 'CustomerContact', 'KundRef2': 'CustomerReference', + 'GodsMarke1': 'ShippingInfo', 'GodsMarke2': 'InternalInfo', + 'TA_MailNotified': 'ShippingEmail', 'TA_PhonNotifiedNo': 'ShippingPhone', + 'TA_SMSNotifiedNo': 'ShippingSMS', 'LevSattKod': 'ShippingTypeCode'} + __to_dict_only__ = ('OrderNr', 'FtgNr', 'OrdDatum', 'OrdStat', 'CompanyName', 'LevSattKod', + 'OrdLevAdr1', 'OrdLevAdr2', 'OrdLevAdr3', + 'OrdLevAdrLandsKod', 'KundBestNr', 'KundRef2', 'GodsMarke1', + 'GodsMarke2', 'OrderItems', 'AddrPostalCode', 'AddrCity', + 'TA_MailNotified', 'TA_PhonNotifiedNo', 'TA_SMSNotifiedNo') -# Base companies for cusomters and suppliers -Order = Base.classes.oh # Orders by customers -DelivLoc = Base.classes.lp # Connections between a delivery company and customer company + __dict_args__ = { + 'adapters': { + **{ + 'OrdDatum': lambda ord_date, *_: ord_date.strftime("%Y-%m-%d"), + }, + **RawBaseModel.__dict_args__['adapters'] + } + } + + OrderNr = Column(Integer, primary_key=True) + FtgNr = Column(String, ForeignKey('fr.FtgNr')) + + Company = relationship('Company', uselist=False) + OrderItems = relationship('OrderItem', uselist=True, back_populates="Order", lazy='joined') + + @hybrid_property + def CompanyName(self): + return self.Company.FtgNamn if self.Company else "" + + @CompanyName.setter + def CompanyName(self, value): + return + + @hybrid_property + def AddrPostalCode(self): + if not self.OrdLevAdr4: + return + s = re.split('(?!\d)\s(?!\d)', self.OrdLevAdr4) + return s[0] if len(s) > 1 else '' + + @AddrPostalCode.setter + def AddrPostalCode(self, value): + self.OrdLevAdr4 = value + (self.OrdLevAdr4 if self.OrdLevAdr4 else '') + + @hybrid_property + def AddrCity(self): + if not self.OrdLevAdr4: + return + s = re.split('(?!\d)\s(?!\d)', self.OrdLevAdr4, maxsplit=1) + return s[1] if len(s) > 1 else '' + + @AddrCity.setter + def AddrCity(self, value): + self.OrdLevAdr4 = (self.OrdLevAdr4 if self.OrdLevAdr4 else '') + ' ' + value + + def create(self, webusername=None): + # TODO: Extend with additional functionlity if desired. + self['OrderNr'], invoicing_possible = OrderHead(self['FtgNr'], webusername).callproc() + return self, invoicing_possible + + def save(self, invoiced=False): + payment_method = 'invoice' + if not invoiced: + payment_method = 'card' + PlaceOrder( + self['FtgNr'], self['OrderNr'], payment_method, data=self.to_dict()).callproc() + + return self + + +class OrderItem(RawBaseModel): + __tablename__ = 'orp' + __column_map__ = {'OrdRadNr': 'OrderRowNumber', 'vb_pris': 'UnitPrice', + 'ArtNr': 'ArticleNumber', 'OrdAntal': 'UnitAmount', + 'OrdAntalAltEnh': 'AltUnitAmount', 'AltEnhetKod': 'AltUnit'} + __to_dict_only__ = ('OrdRadNr', 'vb_pris', 'ArtNr', 'ArticleName', 'OrdAntal', + 'OrdAntalAltEnh', 'AltEnhetKod') + + # Do not serialize order relationship + __dict_args__ = { + 'adapters': { + **{ + Order: None, + }, + **RawBaseModel.__dict_args__['adapters'] + } + } + + OrderNr = Column(Integer, ForeignKey('oh.OrderNr'), primary_key=True) + OrdRadNr = Column(Integer, primary_key=True) + OrdRadNrStrPos = Column(Integer, primary_key=True) + OrdRestNr = Column(Integer, primary_key=True) + + ArtNr = Column(Integer, ForeignKey('ar.ArtNr')) + + Order = relationship('Order', uselist=False) + Article = relationship(Article) + + @hybrid_property + def ArticleName(self): + return self.Article.ArtBeskr if self.Article else "" + + @ArticleName.setter + def ArticleName(self, value): + return + + def save(self): + # TODO: Additional information may be returned if desired. + row_no = OrderRow( + company_no=self['FtgNr'], order_no=self['OrderNr'], item_no=self['ArtNr'], + qty=self['OrdAntal'], qty_alt_unit=self['OrdAntalAltEnh'], + alt_unit=self['AltEnhetKod'], pers_sign='marlin').callproc() + self['OrdRadNr'] = row_no + return self diff --git a/pyjeeves/models/sp_classes.py b/pyjeeves/models/sp_classes.py new file mode 100644 index 0000000..740d694 --- /dev/null +++ b/pyjeeves/models/sp_classes.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +import pymssql +from collections import OrderedDict +from pyjeeves.models import db +from pyjeeves import logging +# from datetime import datetime +# from decimal import Decimal + +logger = logging.getLogger("PyJeeves." + __name__) + + +class StoredProcedure(OrderedDict): + __raw_params = {} + # https://www.mssqltips.com/sqlservertip/1669/generate-a-parameter-list-for-all-sql-server-stored-procedures-and-functions/ # noqa + query = """SELECT SCHEMA_NAME(SCHEMA_ID) AS [Schema], + SO.name AS [ObjectName], + SO.Type_Desc AS [ObjectType (UDF/SP)], + P.parameter_id AS [ParameterID], + P.name AS [ParameterName], + TYPE_NAME(P.user_type_id) AS [ParameterDataType], + P.max_length AS [ParameterMaxBytes], + P.is_output AS [IsOutPutParameter] + FROM sys.objects AS SO + INNER JOIN sys.parameters AS P + ON SO.OBJECT_ID = P.OBJECT_ID + WHERE SO.name LIKE '%Jeeves_Esales_%' OR + SO.name LIKE '%JAPP_spr_LogTrade_%' AND + SO.OBJECT_ID IN ( SELECT OBJECT_ID + FROM sys.objects + WHERE TYPE IN ('P','FN')) + ORDER BY [Schema], SO.name, P.parameter_id""" + + logger.debug("Getting information about stored procedures from database") + + for param in db.execute(query): + if param['ObjectName'] not in __raw_params: + __raw_params[param['ObjectName']] = OrderedDict() + param_name = param['ParameterName'][1:] + __raw_params[param['ObjectName']][param_name] = param + + @classmethod + def get_params_for(cls, procedure_name): + rv = OrderedDict() + for key in cls.__raw_params[procedure_name]: + param = cls.__raw_params[procedure_name][key] + if 'int' in param['ParameterDataType'].lower(): + param_type = int + elif ('money' in param['ParameterDataType'].lower() or + 'decimal' in param['ParameterDataType'].lower() or + 'float' in param['ParameterDataType'].lower() or + 'qty' in param['ParameterDataType'].lower()): + param_type = float + else: + # TODO: Format datetime and perhaps decimal? + param_type = str + + if param['IsOutPutParameter'] == 1: + param_type = pymssql.output(param_type) + else: + param_type = param_type() + + rv[key] = param_type + + return rv + + def __init__(self, procedure_name): + super(StoredProcedure, self).__init__() + self.procedure = procedure_name + self.update(StoredProcedure.get_params_for(self.procedure)) + + def _set_output(self, data=(), ret_resultset=False): + if ret_resultset: + return data + if len(self) != len(data): + raise + for p, k in enumerate(self): + if isinstance(self[k], pymssql.output): + self[k] = data[p] + return self + # Should the original object be unmodified? Return a new object: + # return [(k, data[p]) for p, k in enumerate(self)] + + def callproc(self, resultset=False): + return self._set_output(db.callproc( + self.procedure, + self.values()), + resultset) + + def values(self): + return [value if value else None + for value in super(StoredProcedure, self).values()] + + def __setitem__(self, key, obj): + if (key in self and type(self[key]) is not type(obj) and + obj is not None and not isinstance(self[key], pymssql.output)): + raise TypeError + super(StoredProcedure, self).__setitem__(key, obj) + + +class OrderHead(StoredProcedure): + """Mapping for the Jeeves_Esales_CreateOrder stored procedure parameters + webapp031 and WEBAPP003 determines default order status""" + # TODO: Extend with additional functionlity if desired. + + def __init__(self, company_no, web_user_name): + super(OrderHead, self).__init__('Jeeves_Esales_CreateOrder') + + self['c_CompanyNo'] = company_no + + # Some defaults: + self['c_ForetagKod'] = 1 # Hardcoded to LK + self['c_PersSign'] = 'marlin' # From API profile, or default + # self['c_OrderType'] = None # Default set by WEBAPP008 + # self['c_TemplateRowID'] = None # No template used + # self['c_Saljare'] = None # 600 # From API profile, or default + + # Unique ID added to 'kpw' when invoicing is allowed. + print(web_user_name) + self['c_webUserName'] = web_user_name + + # self['LangID'] = 0 # Default to Swedish + # self['BatchId'] = '' # unused + + # self['Run_Type'] = None # Could be 'R', but doesn't work + + # self['Edit'] = None # Custom ordertext, currently not used in procedure + # self['EditExt'] = None # Custom ordertext, currently not used in procedure + + # self['Lagstalle'] = None # '1' # Used to override customer default + # self['OverrideCreditLimit'] = 0 # Set to a char to override credit limit + # self['OrderNumber'] = pymssql.output(int) + + def callproc(self): + super(OrderHead, self).callproc() + # If call succeeded, then order is allowed to be invoiced. + return self['o_OrderNumber'], bool(self['c_webUserName']) + + +class OrderRow(StoredProcedure): + """Mapping for the Jeeves_Esales_AddOrderRow stored procedure parameters + AltEnhetKod logic needs to have been added to the procedure""" + + def __init__(self, company_no, order_no, item_no, + qty=None, qty_alt_unit=None, alt_unit='', pers_sign='biz'): + super(OrderRow, self).__init__('Jeeves_Esales_AddOrderRow') + + self['c_CompanyNo'] = str(company_no) + self['c_OrderNumber'] = int(order_no) + self['c_ItemNo'] = str(item_no) + self['c_Qty'] = float(qty) if qty else None + self['c_QtyAltEnh'] = float(qty_alt_unit) if qty_alt_unit else None + self['c_AltEnhetKod'] = str(alt_unit) + self['c_PersSign'] = str(pers_sign) + + # Used to set date for delivery (c_OrdBegLevDat) and (c_OrdBerLevDat) + self['c_RequestedDate'] = None + + # Some defaults: + self['c_ForetagKod'] = 1 # Hardcoded to LK + # self['OrderNumber'] = 0 # Required, ordernumber to add row to + # self['webUserName'] = order_head['webUserName'] + # self['CompanyNo'] = order_head['CompanyNo'] + # self['PersSign'] = order_head['PersSign'] + # self['LangID'] = order_head['LangID'] + # self['ItemNo'] = '' # Required, item to create row for + # self['c_Qty'] = None # Only one of qty or qtyaltenh may be used + # self['QtyAltEnh'] = None + # self['RequestedDate'] = '' # unused + # self['BatchId'] = order_head['BatchId'] + # self['ArtSerieNr'] = '' # unused + # self['c_OrderType'] = None + # self['Run_Type'] = None # Could be 'R', but doesn't work + # self['c_TemplateRowID'] = None # No template used + # self['Edit'] = None # Custom order row text + # self['EditExt'] = None # Custom extended order row text + # self['Lagstalle'] = None # str: use default + # self['AltEnhetKod'] = '' # Override default alternative unit if desired + # self['AllocateAvailable'] = 0 # unused + # self['OverrideCreditLimit'] = 0 # Set to a char to override credit limit + # self['o_OrderRow'] = pymssql.output(int) + # self['o_NextQty'] = pymssql.output(float) + # self['o_NextDate'] = pymssql.output(str) + # self['o_LastQty'] = pymssql.output(float) + # self['o_LastDate'] = pymssql.output(str) + # self['o_AllocatedQty'] = pymssql.output(float) + # self['o_AllocatedDate'] = pymssql.output(str) + + def callproc(self): + super(OrderRow, self).callproc() + return self['o_OrderRow'] + + +class PlaceOrder(StoredProcedure): + """Mapping for the Jeeves_Esales_PlaceOrder stored procedure parameters + webapp031 and WEBAPP003 determines default order status""" + + def __init__(self, company_no, order_no, payment_method='card', data={}): + super(PlaceOrder, self).__init__('Jeeves_Esales_PlaceOrder') + + self['c_CompanyNo'] = str(company_no) + self['c_OrderNumber'] = int(order_no) + self['c_kundref2'] = data.get('CustomerContact') # Er ref, kontaktperson + self['c_kundbestnr'] = data.get('CustomerReference') + self['c_editext'] = data.get('ExtraText') # Extern text + self['c_CoName'] = data.get('AddrName') + self['c_Addr1'] = data.get('AddrCO') # Lev.adress, c/o + self['c_Addr2'] = data.get('AddrStreet') + self['c_PostalCode'] = data.get('AddrPostalCode') + self['c_City'] = data.get('AddrCity') + self['c_CountryCode'] = data.get('AddrCountry', 'SE') # Ex: SE, FI etc. + self['c_godsmarke1'] = data.get('ShippingInfo') + self['c_godsmarke2'] = data.get('InternalInfo') # Kundspecifikt + + notify_info = NotifyInfo(company_no).callproc() + self['c_TA_MailNotified'] = data.get('ShippingEmail', notify_info.get('email')) + self['c_TA_PhonNotifiedNo'] = data.get('ShippingPhone', notify_info.get('phone')) + self['c_TA_SMSNotifiedNo'] = data.get('ShippingSMS', notify_info.get('sms')) + + # 1 = card, else invoice. Card requires manual update. + self['c_PaymentType'] = '1' if payment_method is 'card' else '0' + + self['c_LevSattKod'] = 2 # 2 = Schenker, 4 = Collect + self['c_orderStatus'] = None # Override orderStatusCode when using invoicing + + self['c_ForetagKod'] = 1 # Hardcoded to LK + self['c_orderStatus'] = None + self['c_ProvinceCode'] = None # For US customers etc. + + +class NotifyInfo(StoredProcedure): + """Mapping for the JAPP_spr_LogTrade_Get_NotifyInfo stored procedure parameters + webapp031 and WEBAPP003 determines default order status""" + + def __init__(self, company_no): + super(NotifyInfo, self).__init__('JAPP_spr_LogTrade_Get_NotifyInfo') + + self['c_FtgNr'] = str(company_no) + + self['c_ForetagKod'] = 1 # Hardcoded to LK + + def callproc(self): + result = super(NotifyInfo, self).callproc(resultset=True) + ret = {'email': None, 'sms': None, 'phone': None} + + if isinstance(result, list): + for r in result: + if r[1][7:].lower() in ret: + ret[r[1][7:].lower()] = r[0] + + return ret diff --git a/pyjeeves/repositories/__init__.py b/pyjeeves/repositories/__init__.py index f7f45be..b58262c 100644 --- a/pyjeeves/repositories/__init__.py +++ b/pyjeeves/repositories/__init__.py @@ -1,4 +1,5 @@ from .location import Location from .article import Article, ArticleCategory from .company import Company -from .pricelist import PriceList \ No newline at end of file +from .pricelist import PriceList +from .order import Order \ No newline at end of file diff --git a/pyjeeves/repositories/article.py b/pyjeeves/repositories/article.py index c4f5bef..d0ad923 100644 --- a/pyjeeves/repositories/article.py +++ b/pyjeeves/repositories/article.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from pyjeeves.models.raw import Article as ArticleModel, ProductClass, ArticleClass, CommodityGroup +from pyjeeves.models import db from sqlalchemy.sql.expression import and_ from sqlalchemy.orm.exc import NoResultFound @@ -10,14 +11,14 @@ logger = logging.getLogger("PyJeeves." + __name__) # Relocate Jeeves modules to separate folder and let a "master" module handle imports, and setup. class Article(): - """Handles articles in Jeeves""" + """Handles articles in Jeeves, currently filters out all articles with class = 2""" @staticmethod def get(art_no): """ Query an article by number """ try: - return ArticleModel.query.filter_by( - ArtNr=art_no + return db.raw.query(ArticleModel).filter_by( + ArtNr=str(art_no) ).one() except NoResultFound: raise KeyError @@ -25,7 +26,29 @@ class Article(): @staticmethod def get_all(filter_=and_(ArticleModel.ItemStatusCode == 0, ArticleModel.ArtKod != 2)): # .filter_by(ItemStatusCode=0, ArtKod=2) - return ArticleModel.query.filter(filter_).all() + return db.raw.query(ArticleModel).filter(filter_).all() + + @staticmethod + def is_salable(art_no_list=[]): + """ Returns true if all articles are salable, + else false with error information """ + articles = db.raw.query(ArticleModel).filter( + and_(ArticleModel.ArtNr.in_(art_no_list))).all() + + blocked_articles = [article.ArtNr for article in articles + if article.ArtKod == 2 or article.ItemStatusCode != 0] + unknown_articles = [x for x in art_no_list + if x not in set([article.ArtNr for article in articles])] + + if blocked_articles or unknown_articles: + errors = {} + if blocked_articles: + errors['blocked_articles'] = blocked_articles + if unknown_articles: + errors['unknown_articles'] = unknown_articles + return False, errors + + return True, {} class ArticleCategory(): @@ -34,9 +57,9 @@ class ArticleCategory(): @staticmethod def get_all(): # .filter_by(ItemStatusCode=0, ArtKod=2) - prod_classes = ProductClass.query.all() - art_classes = ArticleClass.query.all() - com_groups = CommodityGroup.query.all() + prod_classes = db.raw.query(ProductClass).all() + art_classes = db.raw.query(ArticleClass).all() + com_groups = db.raw.query(CommodityGroup).all() return {'ProductClasses': prod_classes, 'ArticleClasses': art_classes, 'CommodityGroups': com_groups} diff --git a/pyjeeves/repositories/company.py b/pyjeeves/repositories/company.py index 62de891..c8d3bd7 100644 --- a/pyjeeves/repositories/company.py +++ b/pyjeeves/repositories/company.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from pyjeeves.models.raw import Company as CompanyModel, Customer as CustomerModel +from pyjeeves.models import db from sqlalchemy.sql.expression import and_ from pyjeeves import logging @@ -14,18 +15,18 @@ class Company(): @staticmethod def get(ftg_nr): """ Query an article by number """ - return CompanyModel.query.filter_by( + return db.raw.query(CompanyModel).filter_by( FtgNr=ftg_nr ).one() @staticmethod def get_all_active_customers(): - cust = CustomerModel.query.filter(and_(CustomerModel.Makulerad == 0)).all() + cust = db.raw.query(CustomerModel).filter(and_(CustomerModel.Makulerad == 0)).all() return [c.CompanyModel for c in cust] @staticmethod def get_list(ftg_nr=[]): - return CompanyModel.query.filter( + return db.raw.query(CompanyModel).filter( CompanyModel.FtgNr.in_(ftg_nr) ).all() diff --git a/pyjeeves/repositories/order.py b/pyjeeves/repositories/order.py new file mode 100644 index 0000000..de7168c --- /dev/null +++ b/pyjeeves/repositories/order.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- + +from pyjeeves.models.raw import Order as OrderModel, OrderItem as OrderItemModel +from pyjeeves.models import db + +from sqlalchemy.sql.expression import and_ +from sqlalchemy.orm.exc import NoResultFound + +from pyjeeves import logging +logger = logging.getLogger("PyJeeves." + __name__) + + +# Relocate Jeeves modules to separate folder and let a "master" module handle imports, and setup. +class Order(): + """Handles orders in Jeeves""" + + @staticmethod + def get(order_no): + """ Query an order by number """ + try: + return db.raw.query(OrderModel).filter_by( + OrderNr=order_no + ).one() + except NoResultFound: + raise KeyError + + @staticmethod + def get_all_unregistered_order(): + order = OrderModel.query.filter(and_(OrderModel.OrderStatusCode == 00)).all() + return order + + @staticmethod + def get_list(order_no=[]): + return db.raw.query(OrderModel).filter( + OrderModel.OrderNr.in_(order_no) + ).all() + + @staticmethod + def get_all_by_company(ftg_nr=None): + if not ftg_nr: + raise KeyError + return db.raw.query(OrderModel).filter( + OrderModel.FtgNr == str(ftg_nr) + ).all() + + @staticmethod + def create(customer_no, head={}, items=[], web_user_name=None): + head['CompanyNumber'] = str(customer_no) + + # Create order from head dict to get an order number + order, invoice_possible = OrderModel(head).create(web_user_name) + if not order['OrderNr']: + raise + + # Go through order items, if any, and save them to DB. + order['OrderItems'] = Order.create_rows(order['FtgNr'], order['OrderNr'], items) + + # Save the information in the order object + # Boolean argument deceides if order has contact person, and should be set 'registered' + order.save(invoice_possible) + + return order + + @staticmethod + def create_rows(company_no, order_no, items=[]): + rv = [] + + for item in items: + if not isinstance(item, OrderItemModel): + item = OrderItemModel(item) + item['OrderNr'] = order_no + item['FtgNr'] = company_no + rv.append(item.save()) + + return rv + + +if __name__ == '__main__': + + logger.info("Starting TEST") + logger.info("Testing getting an order") + + logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + + # import os + # os.environ['TDSDUMP'] = 'stdout' + + # Jeeves_Esales_CreateOrder + + # All "Jeeves_Esales_" procedures may perhaps be used? + + # select * + # from LKTest.information_schema.routines + # where routine_type = 'PROCEDURE' + # AND SPECIFIC_NAME LIKE '%Jeeves_Esales_%'; + # data = { + # 'OrderNr': 500500, + # 'OrdDatum': datetime.now(), + # 'OrdTyp': 1, + # 'FtgNr': customer_no, + # 'OrdBerLevDat': datetime(2019, 7, 10), + # 'ValKod': 'SEK', + # 'OrderItems': [ + # {'ArtNr': '2005', + # 'OrdAntal': 5.4} + # ], + # 'Saljare': '500', + # 'PersSign': 'marlin', + # 'MomsKod': 2, + # 'BetKod': '10', + # 'LevVillkKod': 3, + # 'LevSattKod': 2, + # 'LagStalle': 0, + # 'ForetagKod': 1 + # } + order_head = { + 'AddrCO': '', + 'AddrCity': 'Uppsala', + 'AddrCountry': 'SE', + 'AddrName': 'Lindvalls Kaffe', + 'AddrPostalCode': '751 82', + 'AddrStreet': 'Kungsgatan 60', + 'CompanyName': 'Lindvalls Kaffe AB (övrigt)', + 'CompanyNumber': '179580', + 'CustomerContact': 'Test beställning', + 'CustomerReference': 'no po number', + 'InternalInfo': 'Test order', + 'OrderNumber': 419040, + 'OrderStatusCode': 13, + 'ShippingEmail': 'order@lindvallskaffe.se', + 'ShippingInfo': 'Lev till godsmottagning', + 'ShippingPhone': '018-480 20 00', + 'ShippingSMS': '0703 25 25 02', + 'ShippingTypeCode': 4} + order_items = [ + {'AltEnhetKod': 'Bricka5,4', + 'ArticleName': 'Lindvalls Mellanrost', + 'ArticleNumber': '2003', + 'OrdAntalAltEnh': '1.00', + 'OrderRowNumber': 10, + 'UnitAmount': '5.40', + 'UnitPrice': '92.00'}, + {'AltEnhetKod': 'Bricka5,4', + 'ArticleName': 'Lindvalls Mellanrost', + 'ArticleNumber': '2003', + 'OrdAntalAltEnh': 1.666666667, + 'OrderRowNumber': 20, + 'UnitAmount': '9.00', + 'UnitPrice': '92.00'}, + {'AltEnhetKod': 'Bricka5,4', + 'ArticleName': 'Lindvalls Mellanrost', + 'ArticleNumber': '2003', + 'OrdAntalAltEnh': '5.00', + 'OrderRowNumber': 30, + 'UnitAmount': '27.00', + 'UnitPrice': '92.00'}] + + # print(Order.get_list(['406569', '179580', '2440070', '179584'])) + from pprint import pprint + # pprint(Order.get(7000028).to_dict()) + pprint(Order.create('179584', order_head, order_items).to_dict()) + # pprint(Order.get('419033').to_dict()) + + # c1 = CompanyModel.query.filter_by(FtgNr="406569").first() + # print(c1) + # logger.info(c1.json) diff --git a/pyjeeves/repositories/pricelist.py b/pyjeeves/repositories/pricelist.py index b5007f6..cf25e0f 100644 --- a/pyjeeves/repositories/pricelist.py +++ b/pyjeeves/repositories/pricelist.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from pyjeeves.models.raw import PriceList as PriceListModel +from pyjeeves.models import db from sqlalchemy.orm.exc import NoResultFound from pyjeeves import logging @@ -15,7 +16,7 @@ class PriceList(): def get(price_list_no): """ Query a price list by number """ try: - return PriceListModel.query.filter_by( + return db.raw.query(PriceListModel).filter_by( PrisLista=price_list_no ).one() except NoResultFound: