diff --git a/.gitignore b/.gitignore index 9a52e5b..07ddefb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ +*~ +*.pyc +dist/ pyCOREPOS.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b1c709..2c26baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,177 @@ All notable changes to pyCOREPOS will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.5.1 (2025-02-20) + +### Fix + +- add `Product.default_vendor_item` convenience property + +## v0.5.0 (2025-02-01) + +### Feat + +- use true column names for transaction data models + +### Fix + +- define common base schema for Product model +- add `Parameter` model for lane_op +- add model for lane_trans `LocalTrans` + +## v0.4.0 (2025-01-24) + +### Feat + +- add common base class for `dtransactions` and similar models + +### Fix + +- add `Employee` model for lane_op +- fix ordering of name columns for MemberInfo + +## v0.3.5 (2025-01-15) + +### Fix + +- add workaround to avoid missing schema columns + +## v0.3.4 (2025-01-15) + +### Fix + +- misc. cleanup for sales batch models +- add more enums for batch discount type, editor UI + +## v0.3.3 (2025-01-13) + +### Fix + +- remove `autoincrement` option for composite PK fields + +## v0.3.2 (2025-01-11) + +### Fix + +- add base class for all transaction tables, views +- add `MemberType.ignore_sales` column +- add model for `MasterSuperDepartment` + +## v0.3.1 (2024-12-17) + +### Fix + +- add `wicable`, `active` columns for Department model + +## v0.3.0 (2024-08-06) + +### Feat + +- add model for `MemberContactPreference` (`op.memContactPrefs`) +- add model for `CustomReceiptLine` (`op.customReceipt`) + +## v0.2.1 (2024-07-04) + +### Fix + +- add API methods, `get_employees()` and `get_employee()` +- remove `Change` data model +- remove dependency for `six` package + +## v0.2.0 (2024-06-10) + +### Feat + +- switch from setup.cfg to pyproject.toml + hatchling + +## [0.1.20] - 2024-05-29 +### Changed +- Add enum for CORE (Office) DB types. + +## [0.1.19] - 2023-11-01 +### Changed +- Fix data types for tax, voided in `dtransactions`. +- Fix synonym for `dtransactions.tax`. + +## [0.1.18] - 2023-10-12 +### Changed +- Fix the `Department.tax_rate` relationship. +- Let `MemberInfo.dates` be an object, not a list. + +## [0.1.17] - 2023-10-07 +### Changed +- Rename module to `corepos.db.office_arch`. + +## [0.1.16] - 2023-09-15 +### Changed +- Add model for `office_op.Tender`. + +## [0.1.15] - 2023-09-13 +### Changed +- Add model for `CustomerNotifications` table. + +## [0.1.14] - 2023-09-07 +### Changed +- Tweak primary key for StockPurchase model. + +## [0.1.13] - 2023-09-02 +### Changed +- Add models for StockPurchase and EquityLiveBalance. + +## [0.1.12] - 2023-06-12 +### Changed +- Add `get_member_types()` method for CORE API. +- Rename model for `custdata` to `CustomerClassic`. +- Add note about `meminfo.email_2` field, aka. "alt. phone". + +## [0.1.11] - 2023-06-02 +### Changed +- Add support for htdigest auth when using CORE webservices API. + +## [0.1.10] - 2023-05-17 +### Changed +- Replace `setup.py` contents with `setup.cfg`. + +## [0.1.9] - 2023-05-01 +### Changed +- Require SQLAlchemy 1.4.x. + +## [0.1.8] - 2023-01-02 +### Changed +- Add basic `TransactionDetail` for trans archive model. +- Delete `productUser` record when `products` record is deleted. + +## [0.1.7] - 2022-03-02 +### Changed +- Remove deprecation warning for `corepos.db`. +- Add model for `UserGroup`. + +## [0.1.6] - 2021-11-04 +### Changed +- Add `User` model for office_op. +- Add proper support for `str(Suspension)`. +- Add the `custdata` model for lane_op DB. + +## [0.1.5] - 2021-08-31 +### Changed +- Add lane_op model for Department. + +## [0.1.4] - 2021-08-02 +### Changed +- Add schema for `TableSyncRules`. + +## [0.1.3] - 2021-07-21 +### Changed +- Add basic 'lane_op' DB schema. + +## [0.1.2] - 2021-06-11 +### Changed +- Several more updates, mostly a "save point" release. + +## [0.1.1] - 2020-09-16 +### Added +- A ton of updates, mostly a "save point" release. + ## [0.1.0] - 2020-02-27 ### Added - Initial version of the package; defines some basic table mappings. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd69797 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ + +# pyCOREPOS + +A Python interface to the [CORE POS](https://github.com/CORE-POS) +system. diff --git a/README.rst b/README.rst deleted file mode 100644 index 0b34d2f..0000000 --- a/README.rst +++ /dev/null @@ -1,7 +0,0 @@ - -pyCOREPOS -========= - -A Python interface to the `CORE POS`_ system. - -.. _CORE POS: https://github.com/CORE-POS diff --git a/corepos/__init__.py b/corepos/__init__.py index e69de29..22b270b 100644 --- a/corepos/__init__.py +++ b/corepos/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2020 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +CORE POS Interface +""" + +from ._version import __version__ diff --git a/corepos/_version.py b/corepos/_version.py index e41b669..555fee3 100644 --- a/corepos/_version.py +++ b/corepos/_version.py @@ -1,3 +1,6 @@ # -*- coding: utf-8; -*- -__version__ = '0.1.0' +from importlib.metadata import version + + +__version__ = version('pyCOREPOS') diff --git a/corepos/api.py b/corepos/api.py new file mode 100644 index 0000000..a24b906 --- /dev/null +++ b/corepos/api.py @@ -0,0 +1,578 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2024 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +CORE-POS webservices API +""" + +import json +import logging + +import requests +from requests.auth import HTTPDigestAuth + + +log = logging.getLogger(__name__) + + +class CoreAPIError(Exception): + """ + Base class for errors coming from the CORE API proper. + """ + + def __init__(self, message): + self.message = message + + def __str__(self): + return "CORE API returned an error: {}".format(self.message) + + +class CoreWebAPI(object): + """ + Client implementation for the CORE webservices API. + + :param str url: URL to the CORE webservices API, + e.g. ``'http://localhost/fannie/ws/'`` + + :param bool verify: How to handle certificate validation for HTTPS + URLs. This value is passed as-is to the ``requests`` library, + so see those docs for more info. The default value for this is + ``True`` because the assumption is that security should be on + by default. Set it to ``False`` in order to disable validation + entirely, e.g. for self-signed certs. (This may also be needed + for basic HTTP URLs?) Other values may be possible also; again + see the ``requests`` docs for more info. + + :param htdigest_username: Username for htdigest authentication, if + applicable. + + :param htdigest_password: Password for htdigest authentication, if + applicable. + """ + + def __init__( + self, + url, + verify=True, + htdigest_username=None, + htdigest_password=None, + ): + self.url = url + self.verify = verify + + self.session = requests.Session() + + if htdigest_username and htdigest_password: + self.session.auth = HTTPDigestAuth(htdigest_username, + htdigest_password) + + def post(self, params, method=None): + """ + Issue a POST request to the API, with the given ``params``. If not + specified, ``method`` will be CORE's ``FannieEntity`` webservice. + """ + if not method: + method = 'FannieEntity' + if '\\' not in method: + method = r'\COREPOS\Fannie\API\webservices\{}'.format(method) + + payload = { + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + # we're not dealing with async here, so KISS for this 'id' + # https://stackoverflow.com/questions/4390369/json-rpc-how-can-one-make-a-unique-id#comment4786119_4391070 + 'id': 1, + } + + response = self.session.post(self.url, data=json.dumps(payload), + verify=self.verify) + response.raise_for_status() + return response + + def parse_response(self, response, method=None): + """ + Generic method to "parse" a response from the API. Really this just + converts the JSON to a dict (etc.), and then checks for error. If an + error is found in the response, it will be raised here. + """ + try: + js = response.json() + except: + raise CoreAPIError("Received invalid response: {}".format(response.content)) + + if 'error' in js: + raise CoreAPIError(js['error']) + + # note, the result data format may depend on the API method involved + if method == 'FannieMember': + return js['result'] + + # assuming typical FannieEntity result here + assert set(js.keys()) == set(['jsonrpc', 'id', 'result']) + assert set(js['result'].keys()) == set(['result']) + return js['result']['result'] + + def get_member_types(self): + """ + Fetch all Member Type records from CORE. + + :returns: A (potentially empty) list of member type dict records. + """ + params = { + 'entity': 'Memtype', + 'submethod': 'get', + 'columns': {}, + } + + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_members(self): + """ + Fetch all Member records from CORE. + + :returns: A (potentially empty) list of member dict records. + """ + params = { + 'method': 'get', + 'cardNo': None, + } + response = self.post(params, method='FannieMember') + result = self.parse_response(response, method='FannieMember') + return result + + def get_member(self, cardNo): + """ + Fetch an existing Member record from CORE. + + :returns: Either a member dict record, or ``None``. + """ + params = { + 'cardNo': cardNo, + 'method': 'get', + } + response = self.post(params, method='FannieMember') + result = self.parse_response(response, method='FannieMember') + if result: + return result + + def set_member(self, cardNo, **kwargs): + """ + Update an existing Member record in CORE. + + :returns: Boolean indicating success of the operation. + + .. warning:: + Only simple updates have been attempted thus far; have yet to try + creation or deletion. Neither of those should be expected to work. + """ + kwargs['cardNo'] = cardNo + params = { + 'cardNo': cardNo, + 'method': 'set', + 'member': kwargs, + } + response = self.post(params, method='FannieMember') + result = self.parse_response(response, method='FannieMember') + if result: + return result + + def get_employees(self, **columns): + """ + Fetch some or all of Employee records from CORE. + + :returns: A (potentially empty) list of employee dict records. + """ + params = { + 'entity': 'Employees', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_employee(self, emp_no, **columns): + """ + Fetch an existing Employee record from CORE. + + :returns: Either a employee dict record, or ``None``. + """ + columns['emp_no'] = emp_no + params = { + 'entity': 'Employees', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s employee results", len(result)) + return json.loads(result[0]) + + def get_stores(self, **columns): + """ + Fetch some or all of Store records from CORE. + + :returns: A (potentially empty) list of store dict records. + + To fetch all stores:: + + api.get_stores() + + To fetch only stores named "Headquarters":: + + api.get_stores(description='Headquarters') + """ + params = { + 'entity': 'Stores', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_departments(self, **columns): + """ + Fetch some or all of Department records from CORE. + + :returns: A (potentially empty) list of department dict records. + + To fetch all departments:: + + api.get_departments() + + To fetch only departments named "Grocery":: + + api.get_departments(dept_name='Grocery') + """ + params = { + 'entity': 'Departments', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_department(self, dept_no, **columns): + """ + Fetch an existing Department record from CORE. + + :returns: Either a department dict record, or ``None``. + """ + columns['dept_no'] = dept_no + params = { + 'entity': 'Departments', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s department results", len(result)) + return json.loads(result[0]) + + def set_department(self, dept_no, **columns): + """ + Update an existing Department record in CORE. + + :returns: Boolean indicating success of the operation. + + .. note:: + Currently this is being used to create a *new* department also. CORE's + ``departments`` table does not use auto-increment for its PK, which + means we must provide one even when creating; therefore this method + may be used for that. + """ + columns['dept_no'] = dept_no + params = { + 'entity': 'Departments', + 'submethod': 'set', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return json.loads(result) + + def get_subdepartments(self, **columns): + """ + Fetch some or all of Subdepartment records from CORE. + + :returns: A (potentially empty) list of subdepartment dict records. + + To fetch all subdepartments:: + + api.get_subdepartments() + + To fetch only subdepartments named "Grocery":: + + api.get_subdepartments(subdept_name='Grocery') + """ + params = { + 'entity': 'SubDepts', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_subdepartment(self, subdept_no, **columns): + """ + Fetch an existing Subdepartment record from CORE. + + :returns: Either a subdepartment dict record, or ``None``. + """ + columns['subdept_no'] = subdept_no + params = { + 'entity': 'SubDepts', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s subdepartment results", len(result)) + return json.loads(result[0]) + + def set_subdepartment(self, subdept_no, **columns): + """ + Update an existing Subdepartment record in CORE. + + :returns: Boolean indicating success of the operation. + + .. note:: + Currently this is being used to create a *new* subdepartment also. CORE's + ``subdepartments`` table does not use auto-increment for its PK, which + means we must provide one even when creating; therefore this method + may be used for that. + """ + columns['subdept_no'] = subdept_no + params = { + 'entity': 'SubDepts', + 'submethod': 'set', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return json.loads(result) + + def get_vendors(self, **columns): + """ + Fetch some or all of Vendor records from CORE. + + :returns: A (potentially empty) list of vendor dict records. + + To fetch all vendors:: + + api.get_vendors() + + To fetch only vendors named "UNFI":: + + api.get_vendors(vendorName='UNFI') + """ + params = { + 'entity': 'Vendors', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_vendor(self, vendorID, **columns): + """ + Fetch an existing Vendor record from CORE. + + :returns: Either a vendor dict record, or ``None``. + """ + columns['vendorID'] = vendorID + params = { + 'entity': 'Vendors', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s vendor results", len(result)) + return json.loads(result[0]) + + def set_vendor(self, vendorID, **columns): + """ + Update an existing Vendor record in CORE. + + :returns: Boolean indicating success of the operation. + + .. note:: + Currently this is being used to create a *new* vendor also. CORE's + ``vendors`` table does not use auto-increment for its PK, which + means we must provide one even when creating; therefore this method + may be used for that. + """ + columns['vendorID'] = vendorID + params = { + 'entity': 'Vendors', + 'submethod': 'set', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return json.loads(result) + + def get_products(self, **columns): + """ + Fetch some or all of Product records from CORE. + + :returns: A (potentially empty) list of product dict records. + + To fetch all products:: + + api.get_products() + + To fetch only products with brand name "Braggs":: + + api.get_products(brand='Braggs') + """ + params = { + 'entity': 'Products', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_product(self, upc, **columns): + """ + Fetch an existing Product record from CORE. + + :returns: Either a product dict record, or ``None``. + """ + columns['upc'] = upc + params = { + 'entity': 'Products', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s product results", len(result)) + return json.loads(result[0]) + + def set_product(self, upc, **columns): + """ + Update an existing Product record in CORE. + + :returns: Boolean indicating success of the operation. + + .. note:: + Currently this is being used to create a *new* product also. CORE's + ``products`` table does not use auto-increment for its PK, which + means we must provide one even when creating; therefore this method + may be used for that. + """ + columns['upc'] = upc + params = { + 'entity': 'Products', + 'submethod': 'set', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return json.loads(result) + + def get_vendor_items(self, **columns): + """ + Fetch some or all of VendorItem records from CORE. + + :returns: A (potentially empty) list of vendor item dict records. + + To fetch all vendor items:: + + api.get_vendor_items() + + To fetch only products with brand name "Braggs":: + + api.get_vendor_items(brand='Braggs') + """ + params = { + 'entity': 'VendorItems', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_vendor_item(self, sku, vendorID, **columns): + """ + Fetch an existing VendorItem record from CORE. + + :returns: Either a vendor item dict record, or ``None``. + """ + columns['sku'] = sku + columns['vendorID'] = vendorID + params = { + 'entity': 'VendorItems', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s VendorItem results", len(result)) + return json.loads(result[0]) + + def set_vendor_item(self, sku, vendorID, **columns): + """ + Update an existing VendorItem record in CORE. + + :returns: Boolean indicating success of the operation. + + .. note:: + Currently this is being used to create a *new* product also. CORE's + ``vendorItems`` table does not use auto-increment for its PK, which + means we must provide one even when creating; therefore this method + may be used for that. + """ + columns['sku'] = sku + columns['vendorID'] = vendorID + params = { + 'entity': 'VendorItems', + 'submethod': 'set', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return json.loads(result) diff --git a/corepos/db/__init__.py b/corepos/db/__init__.py index 174fee4..db5c837 100644 --- a/corepos/db/__init__.py +++ b/corepos/db/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar +# Copyright © 2018-2021 Lance Edgar # # This file is part of pyCOREPOS. # @@ -23,12 +23,3 @@ """ Database Interface """ - -from __future__ import unicode_literals, absolute_import - -import warnings -warnings.warn("The `corepos.db` module is deprecated! " - "Please use `corepos.db.office_op` instead.", - DeprecationWarning) - -from corepos.db.office_op import * diff --git a/corepos/db/common/__init__.py b/corepos/db/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/corepos/db/common/op.py b/corepos/db/common/op.py new file mode 100644 index 0000000..30ecc1c --- /dev/null +++ b/corepos/db/common/op.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Common schema for operational data models +""" + +import sqlalchemy as sa + + +class ParameterBase: + """ + Base class for Parameter models, shared by Office + Lane. + """ + store_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) + + lane_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) + + param_key = sa.Column(sa.String(length=100), primary_key=True, nullable=False) + + param_value = sa.Column(sa.String(length=255), nullable=True) + + is_array = sa.Column(sa.Boolean(), nullable=True) + + def __str__(self): + return f"{self.store_id}-{self.lane_id} {self.param_key}" + + +class EmployeeBase: + """ + Base class for Employee models, shared by Office + Lane. + """ + number = sa.Column('emp_no', sa.SmallInteger(), nullable=False, + primary_key=True, autoincrement=False) + + cashier_password = sa.Column('CashierPassword', sa.String(length=50), nullable=True) + + admin_password = sa.Column('AdminPassword', sa.String(length=50), nullable=True) + + first_name = sa.Column('FirstName', sa.String(length=255), nullable=True) + + last_name = sa.Column('LastName', sa.String(length=255), nullable=True) + + job_title = sa.Column('JobTitle', sa.String(length=255), nullable=True) + + active = sa.Column('EmpActive', sa.Boolean(), nullable=True) + + frontend_security = sa.Column('frontendsecurity', sa.SmallInteger(), nullable=True) + + backend_security = sa.Column('backendsecurity', sa.SmallInteger(), nullable=True) + + birth_date = sa.Column('birthdate', sa.DateTime(), nullable=True) + + def __str__(self): + return ' '.join([self.first_name or '', self.last_name or '']).strip() + + +class ProductBase: + """ + Base class for Product models, shared by Office + Lane. + """ + id = sa.Column(sa.Integer(), nullable=False, primary_key=True, autoincrement=True) + + upc = sa.Column(sa.String(length=13), nullable=True) + + description = sa.Column(sa.String(length=30), nullable=True) + + brand = sa.Column(sa.String(length=30), nullable=True) + + formatted_name = sa.Column(sa.String(length=30), nullable=True) + + normal_price = sa.Column(sa.Float(), nullable=True) + + price_method = sa.Column('pricemethod', sa.SmallInteger(), nullable=True) + + group_price = sa.Column('groupprice', sa.Float(), nullable=True) + + quantity = sa.Column(sa.SmallInteger(), nullable=True) + + special_price = sa.Column(sa.Float(), nullable=True) + + special_price_method = sa.Column('specialpricemethod', sa.SmallInteger(), nullable=True) + + special_group_price = sa.Column('specialgroupprice', sa.Float(), nullable=True) + + special_quantity = sa.Column('specialquantity', sa.SmallInteger(), nullable=True) + + special_limit = sa.Column(sa.SmallInteger(), nullable=True) + + start_date = sa.Column(sa.DateTime(), nullable=True) + + end_date = sa.Column(sa.DateTime(), nullable=True) + + department_number = sa.Column('department', sa.SmallInteger(), nullable=True) + + size = sa.Column(sa.String(length=9), nullable=True) + + tax_rate_id = sa.Column('tax', sa.SmallInteger(), nullable=True) + + foodstamp = sa.Column(sa.Boolean(), nullable=True) + + scale = sa.Column(sa.Boolean(), nullable=True) + + scale_price = sa.Column('scaleprice', sa.Float(), nullable=True) + + mix_match_code = sa.Column('mixmatchcode', sa.String(length=13), nullable=True) + + created = sa.Column(sa.DateTime(), nullable=True) + + modified = sa.Column(sa.DateTime(), nullable=True) + + tare_weight = sa.Column('tareweight', sa.Float(), nullable=True) + + discount = sa.Column(sa.SmallInteger(), nullable=True) + + discount_type = sa.Column('discounttype', sa.SmallInteger(), nullable=True) + + line_item_discountable = sa.Column(sa.Boolean(), nullable=True) + + unit_of_measure = sa.Column('unitofmeasure', sa.String(length=15), nullable=True) + + wicable = sa.Column(sa.SmallInteger(), nullable=True) + + quantity_enforced = sa.Column('qttyEnforced', sa.Boolean(), nullable=True) + + id_enforced = sa.Column('idEnforced', sa.SmallInteger(), nullable=True) + + cost = sa.Column(sa.Float(), nullable=True) + + special_cost = sa.Column(sa.Float(), nullable=True) + + received_cost = sa.Column(sa.Float(), nullable=True) + + in_use = sa.Column('inUse', sa.Boolean(), nullable=True) + + numflag = sa.Column(sa.Integer(), nullable=True) + + subdepartment_number = sa.Column('subdept', sa.SmallInteger(), nullable=True) + + deposit = sa.Column(sa.Float(), nullable=True) + + local = sa.Column(sa.Integer(), nullable=True, default=0) + + store_id = sa.Column(sa.SmallInteger(), nullable=True) + + default_vendor_id = sa.Column(sa.Integer(), nullable=True) + + current_origin_id = sa.Column(sa.Integer(), nullable=True) + + auto_par = sa.Column(sa.Float(), nullable=True, default=0) + + price_rule_id = sa.Column(sa.Integer(), nullable=True, default=0) + + last_sold = sa.Column(sa.DateTime(), nullable=True) diff --git a/corepos/db/common/trans.py b/corepos/db/common/trans.py new file mode 100644 index 0000000..9ec5601 --- /dev/null +++ b/corepos/db/common/trans.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Common schema for transaction data models +""" + +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr + + +class TransactionDetailBase: + """ + Base class for POS transaction detail models, shared by Office + + Lane. + """ + + # register + register_no = sa.Column(sa.Integer(), nullable=True) + + # txn + trans_id = sa.Column(sa.Integer(), nullable=True) + trans_no = sa.Column(sa.Integer(), nullable=True) + trans_type = sa.Column(sa.String(length=1), nullable=True) + trans_subtype = sa.Column(sa.String(length=2), nullable=True) + trans_status = sa.Column(sa.String(length=1), nullable=True) + + # cashier + emp_no = sa.Column(sa.Integer(), nullable=True) + + # customer + card_no = sa.Column(sa.Integer(), nullable=True) + memType = sa.Column(sa.Integer(), nullable=True) + staff = sa.Column(sa.Boolean(), nullable=True) + + ############################## + # remainder is "line item" ... + ############################## + + upc = sa.Column(sa.String(length=13), nullable=True) + + department = sa.Column(sa.Integer(), nullable=True) + + description = sa.Column(sa.String(length=30), nullable=True) + + quantity = sa.Column(sa.Float(), nullable=True) + + scale = sa.Column(sa.Boolean(), nullable=True, default=False) + + cost = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + unitPrice = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + total = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + regPrice = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + tax = sa.Column(sa.SmallInteger(), nullable=True) + + foodstamp = sa.Column(sa.Boolean(), nullable=True) + + discount = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + memDiscount = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + discountable = sa.Column(sa.Boolean(), nullable=True) + + discounttype = sa.Column(sa.Integer(), nullable=True) + + voided = sa.Column(sa.Integer(), nullable=True) + + percentDiscount = sa.Column(sa.Integer(), nullable=True) + + ItemQtty = sa.Column(sa.Float(), nullable=True) + + volDiscType = sa.Column(sa.Integer(), nullable=True) + + volume = sa.Column(sa.Integer(), nullable=True) + + VolSpecial = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + mixMatch = sa.Column(sa.String(length=13), nullable=True) + + matched = sa.Column(sa.Boolean(), nullable=True) + + numflag = sa.Column(sa.Integer(), nullable=True, default=0) + + charflag = sa.Column(sa.String(length=2), nullable=True) + + def __str__(self): + txnid = '-'.join([str(val) for val in [self.register_no, + self.trans_no, + self.trans_id]]) + return f"{txnid} {self.description or ''}" diff --git a/corepos/db/lane_op/__init__.py b/corepos/db/lane_op/__init__.py new file mode 100644 index 0000000..884fe9f --- /dev/null +++ b/corepos/db/lane_op/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2021 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +"Lane Operational" Database Interface +""" + +from sqlalchemy import orm + + +Session = orm.sessionmaker() diff --git a/corepos/db/lane_op/model.py b/corepos/db/lane_op/model.py new file mode 100644 index 0000000..456b1b8 --- /dev/null +++ b/corepos/db/lane_op/model.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Data model for CORE POS "lane_op" DB +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from corepos.db.common import op as common + + +Base = orm.declarative_base() + + +class Parameter(common.ParameterBase, Base): + """ + Data model for ``parameters`` table. + """ + __tablename__ = 'parameters' + + +class Employee(common.EmployeeBase, Base): + """ + Data model for ``employees`` table. + """ + __tablename__ = 'employees' + + +class Department(Base): + """ + Represents a department within the organization. + """ + __tablename__ = 'departments' + + number = sa.Column('dept_no', sa.SmallInteger(), nullable=False, + primary_key=True, autoincrement=False) + + name = sa.Column('dept_name', sa.String(length=30), nullable=True) + + tax = sa.Column('dept_tax', sa.Boolean(), nullable=True) + + food_stampable = sa.Column('dept_fs', sa.Boolean(), nullable=True) + + limit = sa.Column('dept_limit', sa.Float(), nullable=True) + + minimum = sa.Column('dept_minimum', sa.Float(), nullable=True) + + discount = sa.Column('dept_discount', sa.Boolean(), nullable=True) + + see_id = sa.Column('dept_see_id', sa.SmallInteger(), nullable=True) + + modified = sa.Column(sa.DateTime(), nullable=True) + + modified_by_id = sa.Column('modifiedby', sa.Integer(), nullable=True) + + margin = sa.Column(sa.Float(), nullable=False) + + sales_code = sa.Column('salesCode', sa.Integer(), nullable=False) + + member_only = sa.Column('memberOnly', sa.SmallInteger(), nullable=False) + + line_item_discount = sa.Column(sa.Boolean(), nullable=True) + + wicable = sa.Column('dept_wicable', sa.Boolean(), nullable=True) + + def __str__(self): + return self.name or "" + + +class Product(common.ProductBase, Base): + """ + Data model for ``products`` table. + """ + __tablename__ = 'products' + + +class CustomerClassic(Base): + """ + Represents a customer of the organization. + + https://github.com/CORE-POS/IS4C/blob/master/pos/is4c-nf/lib/models/op/CustdataModel.php + """ + __tablename__ = 'custdata' + # __table_args__ = ( + # sa.ForeignKeyConstraint(['memType'], ['memtype.memtype']), + # ) + + id = sa.Column(sa.Integer(), nullable=False, primary_key=True, autoincrement=True) + + card_number = sa.Column('CardNo', sa.Integer(), nullable=True) + + person_number = sa.Column('personNum', sa.SmallInteger(), nullable=True) + + first_name = sa.Column('FirstName', sa.String(length=30), nullable=True) + + last_name = sa.Column('LastName', sa.String(length=30), nullable=True) + + cash_back = sa.Column('CashBack', sa.Numeric(precision=10, scale=2), nullable=True) + + balance = sa.Column('Balance', sa.Numeric(precision=10, scale=2), nullable=True) + + discount = sa.Column('Discount', sa.SmallInteger(), nullable=True) + + member_discount_limit = sa.Column('MemDiscountLimit', sa.Numeric(precision=10, scale=2), nullable=True) + + charge_limit = sa.Column('ChargeLimit', sa.Numeric(precision=10, scale=2), nullable=True) + + charge_ok = sa.Column('ChargeOk', sa.Boolean(), nullable=True, default=True) + + write_checks = sa.Column('WriteChecks', sa.Boolean(), nullable=True, default=True) + + store_coupons = sa.Column('StoreCoupons', sa.Boolean(), nullable=True, default=True) + + type = sa.Column('Type', sa.String(length=10), nullable=True, default='PC') + + member_type_id = sa.Column('memType', sa.SmallInteger(), nullable=True) + # member_type = orm.relationship( + # MemberType, + # primaryjoin=MemberType.id == member_type_id, + # foreign_keys=[member_type_id], + # doc=""" + # Reference to the :class:`MemberType` to which this member belongs. + # """) + + staff = sa.Column(sa.Boolean(), nullable=True, default=False) + + ssi = sa.Column('SSI', sa.Boolean(), nullable=True, default=False) + + purchases = sa.Column('Purchases', sa.Numeric(precision=10, scale=2), nullable=True, default=0) + + number_of_checks = sa.Column('NumberOfChecks', sa.SmallInteger(), nullable=True, default=0) + + member_coupons = sa.Column('memCoupons', sa.Integer(), nullable=True, default=1) + + blue_line = sa.Column('blueLine', sa.String(length=50), nullable=True) + + shown = sa.Column('Shown', sa.Boolean(), nullable=True, default=True) + + last_change = sa.Column('LastChange', sa.DateTime(), nullable=True) + + def __str__(self): + return "{} {}".format(self.first_name or '', self.last_name or '').strip() + + +# TODO: deprecate / remove this +CustData = CustomerClassic diff --git a/corepos/db/lane_trans/__init__.py b/corepos/db/lane_trans/__init__.py new file mode 100644 index 0000000..8e8c706 --- /dev/null +++ b/corepos/db/lane_trans/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Lane Transaction Database +""" + +from sqlalchemy import orm + + +Session = orm.sessionmaker() diff --git a/corepos/db/lane_trans/model.py b/corepos/db/lane_trans/model.py new file mode 100644 index 0000000..f2245f5 --- /dev/null +++ b/corepos/db/lane_trans/model.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +Data model for CORE POS "lane_trans" DB +""" + +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declared_attr + +from corepos.db.common import trans as common + + +Base = orm.declarative_base() + + +class DTransactionBase(common.TransactionDetailBase): + """ + Base class for ``dtransactions`` and similar models. + """ + pos_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) + + store_id = sa.Column(sa.Integer(), nullable=True, default=0) + date_time = sa.Column('datetime', sa.DateTime(), nullable=True) + + +class DTransaction(DTransactionBase, Base): + """ + Data model for ``dtransactions`` table. + """ + __tablename__ = 'dtransactions' + + +class LocalTransBase(common.TransactionDetailBase): + """ + Base class for ``localtrans`` and similar models. + """ + + @declared_attr + def __table_args__(self): + return ( + sa.PrimaryKeyConstraint('trans_id'), + ) + + date_time = sa.Column('datetime', sa.DateTime(), nullable=True) + + +class LocalTrans(LocalTransBase, Base): + """ + Data model for ``localtrans`` table. + """ + __tablename__ = 'localtrans' + + +class LocalTempTrans(LocalTransBase, Base): + """ + Data model for ``localtemptrans`` table. + """ + __tablename__ = 'localtemptrans' diff --git a/corepos/db/office_arch/__init__.py b/corepos/db/office_arch/__init__.py new file mode 100644 index 0000000..70292e9 --- /dev/null +++ b/corepos/db/office_arch/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2023 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +"Archive" Transaction Database Interface +""" + +from sqlalchemy import orm + + +Session = orm.sessionmaker() diff --git a/corepos/db/office_arch/model.py b/corepos/db/office_arch/model.py new file mode 100644 index 0000000..bc5838f --- /dev/null +++ b/corepos/db/office_arch/model.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2025 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +CORE Office "arch" data model +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from corepos.db.common import trans as common +from corepos.db.office_trans.model import DTransactionBase + + +Base = orm.declarative_base() + + +class BigArchive(DTransactionBase, Base): + """ + Data model for ``bigArchive`` table. + """ + __tablename__ = 'bigArchive' + + +# TODO: deprecate / remove this +TransactionDetail = BigArchive + + +class DLogBase(common.TransactionDetailBase): + """ + Base class for ``dlogBig`` and similar models. + """ + store_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) + + store_id = sa.Column(sa.Integer(), nullable=True, default=0) + pos_row_id = sa.Column(sa.Integer(), nullable=True) + date_time = sa.Column('tdate', sa.DateTime(), nullable=True) + + +class DLogBig(DLogBase, Base): + """ + Data model for ``dlogBig`` view. + """ + __tablename__ = 'dlogBig' diff --git a/corepos/db/office_op/model.py b/corepos/db/office_op/model.py index 824ec07..60ad478 100644 --- a/corepos/db/office_op/model.py +++ b/corepos/db/office_op/model.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar +# Copyright © 2018-2025 Lance Edgar # # This file is part of pyCOREPOS. # @@ -21,81 +21,244 @@ # ################################################################################ """ -CORE POS Data Model +Data model for CORE POS "office_op" DB """ -from __future__ import unicode_literals, absolute_import +import datetime +import logging -import six import sqlalchemy as sa from sqlalchemy import orm -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.associationproxy import association_proxy - -Base = declarative_base() +from corepos.db.common import op as common -@six.python_2_unicode_compatible -class Parameter(Base): +log = logging.getLogger(__name__) + +Base = orm.declarative_base() + + +class StringableDateTime(sa.TypeDecorator): """ - Represents a "parameter" value. + Sort of a hack, to let us string-ify certain DateTime values when + generating "raw" SQL output. + + cf. https://docs.sqlalchemy.org/en/14/faq/sqlexpressions.html#rendering-bound-parameters-inline + """ + impl = sa.DateTime + + def process_literal_param(self, value, dialect): + if value is None: + return 'NULL' + if isinstance(value, datetime.datetime): + return "'{}'".format(value.strftime('%Y-%m-%d %H:%M:%S')) + raise NotImplementedError + + +class Parameter(common.ParameterBase, Base): + """ + Data model for ``parameters`` table. """ __tablename__ = 'parameters' - store_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) - lane_id = sa.Column(sa.SmallInteger(), primary_key=True, nullable=False) +class TableSyncRule(Base): + """ + Represents a "table sync rule" value. + """ + __tablename__ = 'TableSyncRules' - param_key = sa.Column(sa.String(length=100), primary_key=True, nullable=False) + # nb. this should be autoincrement, but we can't do that + # automatically via sqlalchemy when PK is composite + id = sa.Column('tableSyncRuleID', sa.Integer(), nullable=False, primary_key=True) - param_value = sa.Column(sa.String(length=255), nullable=True) + table_name = sa.Column('tableName', sa.String(length=255), nullable=False, primary_key=True) - is_array = sa.Column(sa.Boolean(), nullable=True) + rule = sa.Column(sa.String(length=255), nullable=True) def __str__(self): - return "{}-{} {}".format(self.store_id, self.lane_id, self.param_key) + return "{}: {}".format(self.table_name, self.rule) + + +class UserGroup(Base): + """ + Represents a user/group assignment. + """ + __tablename__ = 'userGroups' + + group_id = sa.Column('gid', sa.Integer(), nullable=False, + primary_key=True, autoincrement=False) + + name = sa.Column(sa.String(length=50), nullable=True) + + username = sa.Column(sa.String(length=50), nullable=False, + primary_key=True) + + def __str__(self): + return self.name or "" + + +class User(Base): + """ + Represents a user within CORE Office. + """ + __tablename__ = 'Users' + + name = sa.Column(sa.String(length=50), nullable=False, primary_key=True) + + password = sa.Column(sa.String(length=255), nullable=True) + + salt = sa.Column(sa.String(length=10), nullable=True) + + uid = sa.Column(sa.String(length=4), nullable=True) + + session_id = sa.Column(sa.String(length=50), nullable=True) + + real_name = sa.Column(sa.String(length=75), nullable=True) + + email = sa.Column(sa.String(length=75), nullable=True) + + totp_url = sa.Column('totpURL', sa.String(length=255), nullable=True) + + def __str__(self): + return self.name or "" + + +class Store(Base): + """ + Represents a known store. + """ + __tablename__ = 'Stores' + + storeID = sa.Column(sa.Integer(), nullable=False, primary_key=True, autoincrement=True) + id = orm.synonym('storeID') + + description = sa.Column(sa.String(length=50), nullable=True) + + db_host = sa.Column('dbHost', sa.String(length=50), nullable=True) + + db_driver = sa.Column('dbDriver', sa.String(length=15), nullable=True) + + db_user = sa.Column('dbUser', sa.String(length=25), nullable=True) + + db_password = sa.Column('dbPassword', sa.String(length=25), nullable=True) + + trans_db = sa.Column('transDB', sa.String(length=20), nullable=True) + + op_db = sa.Column('opDB', sa.String(length=20), nullable=True) + + push = sa.Column(sa.Boolean(), nullable=True, default=True) + + pull = sa.Column(sa.Boolean(), nullable=True, default=True) + + has_own_items = sa.Column('hasOwnItems', sa.Boolean(), nullable=True, default=True) + + web_service_url = sa.Column('webServiceUrl', sa.String(length=255), nullable=True) + + def __str__(self): + return self.description or "" + + +class MasterSuperDepartment(Base): + """ + A department may belong to more than one superdepartment, but has + one "master" superdepartment. This avoids duplicating rows in + some reports. By convention, a department's "master" + superdepartment is the one with the lowest superID. + """ + __tablename__ = 'MasterSuperDepts' + + super_id = sa.Column('superID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + + department_id = sa.Column('dept_ID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + + super_name = sa.Column(sa.String(length=50), nullable=True) + + def __str__(self): + return self.super_name or "" + + +class SuperDepartment(Base): + """ + Represents a "super" (parent/child) department mapping. + """ + __tablename__ = 'superdepts' + __table_args__ = ( + sa.ForeignKeyConstraint(['superID'], ['departments.dept_no']), + sa.ForeignKeyConstraint(['dept_ID'], ['departments.dept_no']), + ) + + parent_id = sa.Column('superID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + parent = orm.relationship( + 'Department', + foreign_keys=[parent_id], + doc=""" + Reference to the parent department for this mapping. + """, + backref=orm.backref('_super_children')) + + child_id = sa.Column('dept_ID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + child = orm.relationship( + 'Department', + foreign_keys=[child_id], + doc=""" + Reference to the child department for this mapping. + """, + backref=orm.backref( + '_super_parents', + order_by=parent_id)) + + def __str__(self): + return "{} / {}".format(self.parent, self.child) -@six.python_2_unicode_compatible class Department(Base): """ Represents a department within the organization. """ __tablename__ = 'departments' + __table_args__ = ( + sa.ForeignKeyConstraint(['dept_tax'], ['taxrates.id']), + ) number = sa.Column('dept_no', sa.SmallInteger(), primary_key=True, autoincrement=False, nullable=False) name = sa.Column('dept_name', sa.String(length=30), nullable=True) - tax = sa.Column('dept_tax', sa.Boolean(), nullable=True) + tax_rate_id = sa.Column('dept_tax', sa.SmallInteger(), nullable=True) + tax_rate = orm.relationship('TaxRate') + # TODO: deprecate / remove this + tax = orm.synonym('tax_rate_id') food_stampable = sa.Column('dept_fs', sa.Boolean(), nullable=True) + wicable = sa.Column('dept_wicable', sa.SmallInteger(), nullable=True) + + active = sa.Column(sa.Boolean(), default=True) + limit = sa.Column('dept_limit', sa.Float(), nullable=True) minimum = sa.Column('dept_minimum', sa.Float(), nullable=True) discount = sa.Column('dept_discount', sa.Boolean(), nullable=True) - # TODO: probably should rename this attribute, but to what? - dept_see_id = sa.Column(sa.Boolean(), nullable=True) + see_id = sa.Column('dept_see_id', sa.SmallInteger(), nullable=True) modified = sa.Column(sa.DateTime(), nullable=True) modified_by_id = sa.Column('modifiedby', sa.Integer(), nullable=True) - margin = sa.Column(sa.Float(), nullable=False, default=0) + margin = sa.Column(sa.Float(), nullable=False) - sales_code = sa.Column('salesCode', sa.Integer(), nullable=False, default=0) + sales_code = sa.Column('salesCode', sa.Integer(), nullable=False) - member_only = sa.Column('memberOnly', sa.SmallInteger(), nullable=False, default=0) + member_only = sa.Column('memberOnly', sa.SmallInteger(), nullable=False) def __str__(self): return self.name or '' -@six.python_2_unicode_compatible class Subdepartment(Base): """ Represents a subdepartment within the organization. @@ -120,14 +283,16 @@ class Subdepartment(Base): return self.name or '' -@six.python_2_unicode_compatible class Vendor(Base): """ Represents a vendor from which product may be purchased. """ __tablename__ = 'vendors' - id = sa.Column('vendorID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + # TODO: this maybe should be the pattern we use going forward, for all + # models? for now it was deemed necessary to "match" the API output + vendorID = sa.Column(sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + id = orm.synonym('vendorID') name = sa.Column('vendorName', sa.String(length=50), nullable=True) @@ -184,114 +349,266 @@ class VendorContact(Base): notes = sa.Column(sa.Text(), nullable=True) -@six.python_2_unicode_compatible -class Product(Base): +class VendorDepartment(Base): + """ + Represents specific details / settings for a given vendor in the context of + a given department. + """ + __tablename__ = 'vendorDepartments' + __table_args__ = ( + sa.ForeignKeyConstraint(['vendorID'], ['vendors.vendorID']), + sa.ForeignKeyConstraint(['deptID'], ['departments.dept_no']), + ) + + vendor_id = sa.Column('vendorID', sa.Integer(), primary_key=True, nullable=False) + vendor = orm.relationship( + Vendor, + doc=""" + Reference to the :class:`Vendor` to which this record applies. + """) + + department_id = sa.Column('deptID', sa.Integer(), primary_key=True, nullable=False) + department = orm.relationship( + Department, + doc=""" + Reference to the :class:`Department` to which this record applies. + """) + + name = sa.Column(sa.String(length=125), nullable=True) + + margin = sa.Column(sa.Float(), nullable=True) + + testing = sa.Column(sa.Float(), nullable=True) + + pos_department_id = sa.Column('posDeptID', sa.Integer(), nullable=True) + + +class TaxRate(Base): + """ + Represents a tax rate. Note that this may be a "combo" of various local + tax rates / levels. + """ + __tablename__ = 'taxrates' + + id = sa.Column(sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + + rate = sa.Column(sa.Float(), nullable=True) + + description = sa.Column(sa.String(length=50), nullable=True) + + # TODO: this was not in some older DBs + # sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) + + def __str__(self): + return self.description or "" + + +class TaxRateComponent(Base): + """ + Represents a "component" of a tax rate. + """ + __tablename__ = 'TaxRateComponents' + __table_args__ = ( + sa.ForeignKeyConstraint(['taxRateID'], ['taxrates.id']), + ) + + taxRateComponentID = sa.Column(sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + id = orm.synonym('taxRateComponentID') + + tax_rate_id = sa.Column('taxRateID', sa.Integer()) + tax_rate = orm.relationship(TaxRate, backref='components') + + rate = sa.Column(sa.Float(), nullable=True) + + description = sa.Column(sa.String(length=50), nullable=True) + + def __str__(self): + return self.description or "" + + +class LikeCode(Base): + """ + Represents a "like code" for sake of product pricing. + """ + __tablename__ = 'likeCodes' + + likeCode = sa.Column(sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + id = orm.synonym('likeCode') + + description = sa.Column('likeCodeDesc', sa.String(length=50), nullable=True) + + strict = sa.Column(sa.Boolean(), nullable=True, default=False) + + organic = sa.Column(sa.Boolean(), nullable=True, default=False) + + preferred_vendor_id = sa.Column('preferredVendorID', sa.Integer(), nullable=True, default=0) + + multi_vendor = sa.Column('multiVendor', sa.Boolean(), nullable=True, default=False) + + sort_retail = sa.Column('sortRetail', sa.String(length=255), nullable=True) + + sort_internal = sa.Column('sortInternal', sa.String(length=255), nullable=True) + + products = association_proxy( + '_products', 'product', + creator=lambda p: ProductLikeCode(product=p), + ) + + def __str__(self): + return self.description or "" + + +class OriginCountry(Base): + """ + Represents a country which relates to the "origin" for some product(s). + """ + __tablename__ = 'originCountry' + + id = sa.Column('countryID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + name = sa.Column(sa.String(length=50), nullable=True) + + abbreviation = sa.Column('abbr', sa.String(length=5), nullable=True) + + def __str__(self): + return self.name or self.abbreviation or "" + + +class OriginStateProv(Base): + """ + Represents a state/province which relates to the "origin" for some product(s). + """ + __tablename__ = 'originStateProv' + + id = sa.Column('stateProvID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + name = sa.Column(sa.String(length=50), nullable=True) + + abbreviation = sa.Column('abbr', sa.String(length=5), nullable=True) + + def __str__(self): + return self.name or self.abbreviation or "" + + +class OriginCustomRegion(Base): + """ + Represents a custom region which relates to the "origin" for some product(s). + """ + __tablename__ = 'originCustomRegion' + + id = sa.Column('customID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + name = sa.Column(sa.String(length=50), nullable=True) + + def __str__(self): + return self.name or "" + + +class Origin(Base): + """ + Represents a location which is the "origin" for some product(s). + """ + __tablename__ = 'origins' + __table_args__ = ( + sa.ForeignKeyConstraint(['countryID'], ['originCountry.countryID']), + sa.ForeignKeyConstraint(['stateProvID'], ['originStateProv.stateProvID']), + sa.ForeignKeyConstraint(['customID'], ['originCustomRegion.customID']), + ) + + id = sa.Column('originID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + country_id = sa.Column('countryID', sa.Integer(), nullable=True) + country = orm.relationship(OriginCountry) + + state_prov_id = sa.Column('stateProvID', sa.Integer(), nullable=True) + state_prov = orm.relationship(OriginStateProv) + + custom_id = sa.Column('customID', sa.Integer(), nullable=True) + custom_region = orm.relationship(OriginCustomRegion) + + local = sa.Column(sa.Boolean(), nullable=True, default=0) + + name = sa.Column(sa.String(length=100), nullable=True) + + short_name = sa.Column('shortName', sa.String(length=50), nullable=True) + + def __str__(self): + return self.name or self.short_name or "" + + +class Product(common.ProductBase, Base): """ Represents a product, purchased and/or sold by the organization. """ __tablename__ = 'products' __table_args__ = ( sa.ForeignKeyConstraint(['department'], ['departments.dept_no']), + sa.ForeignKeyConstraint(['subdept'], ['subdepts.subdept_no']), + sa.ForeignKeyConstraint(['tax'], ['taxrates.id']), ) - id = sa.Column(sa.Integer(), primary_key=True, autoincrement=True, nullable=False) - - upc = sa.Column(sa.String(length=13), nullable=True) - - description = sa.Column(sa.String(length=30), nullable=True) - - brand = sa.Column(sa.String(length=30), nullable=True) - - formatted_name = sa.Column(sa.String(length=30), nullable=True) - - normal_price = sa.Column(sa.Float(), nullable=True) - - price_method = sa.Column('pricemethod', sa.SmallInteger(), nullable=True) - - group_price = sa.Column('groupprice', sa.Float(), nullable=True) - - quantity = sa.Column(sa.SmallInteger(), nullable=True) - - special_price = sa.Column(sa.Float(), nullable=True) - - special_price_method = sa.Column('specialpricemethod', sa.SmallInteger(), nullable=True) - - special_group_price = sa.Column('specialgroupprice', sa.Float(), nullable=True) - - special_quantity = sa.Column('specialquantity', sa.SmallInteger(), nullable=True) - - start_date = sa.Column(sa.DateTime(), nullable=True) - - end_date = sa.Column(sa.DateTime(), nullable=True) - - department_number = sa.Column('department', sa.SmallInteger(), nullable=True) - - size = sa.Column(sa.String(length=9), nullable=True) - - tax = sa.Column(sa.SmallInteger(), nullable=True) - - foodstamp = sa.Column(sa.Boolean(), nullable=True) - - scale = sa.Column(sa.Boolean(), nullable=True) - - scale_price = sa.Column('scaleprice', sa.Boolean(), nullable=True, default=False) - - mix_match_code = sa.Column('mixmatchcode', sa.String(length=13), nullable=True) - - modified = sa.Column(sa.DateTime(), nullable=True) - - # advertised = sa.Column(sa.Boolean(), nullable=True) - - tare_weight = sa.Column('tareweight', sa.Float(), nullable=True) - - discount = sa.Column(sa.SmallInteger(), nullable=True) - - discount_type = sa.Column('discounttype', sa.SmallInteger(), nullable=True) - - line_item_discountable = sa.Column(sa.Boolean(), nullable=True) - - unit_of_measure = sa.Column('unitofmeasure', sa.String(length=15), nullable=True) - - wicable = sa.Column(sa.SmallInteger(), nullable=True) - - quantity_enforced = sa.Column('qttyEnforced', sa.Boolean(), nullable=True) - - id_enforced = sa.Column('idEnforced', sa.Boolean(), nullable=True) - - cost = sa.Column(sa.Float(), nullable=True, default=0) - - in_use = sa.Column('inUse', sa.Boolean(), nullable=True) - - flags = sa.Column('numflag', sa.Integer(), nullable=True, default=0) - - subdepartment_number = sa.Column('subdept', sa.SmallInteger(), nullable=True) - - deposit = sa.Column(sa.Float(), nullable=True) - - local = sa.Column(sa.Integer(), nullable=True, default=0) - - store_id = sa.Column(sa.SmallInteger(), nullable=True, default=0) - - default_vendor_id = sa.Column(sa.Integer(), nullable=True, default=0) - - current_origin_id = sa.Column(sa.Integer(), nullable=True, default=0) - department = orm.relationship( Department, - primaryjoin=Department.number == department_number, - foreign_keys=[department_number], + primaryjoin='Department.number == Product.department_number', + foreign_keys='Product.department_number', doc=""" Reference to the :class:`Department` to which the product belongs. """) - vendor = orm.relationship( + tax_rate = orm.relationship(TaxRate) + + # advertised = sa.Column(sa.Boolean(), nullable=True) + + subdepartment = orm.relationship( + Subdepartment, + primaryjoin='Subdepartment.number == Product.subdepartment_number', + foreign_keys='Product.subdepartment_number', + doc=""" + Reference to the :class:`Subdepartment` to which the product belongs. + """) + + default_vendor = orm.relationship( Vendor, - primaryjoin=Vendor.id == default_vendor_id, - foreign_keys=[default_vendor_id], + primaryjoin='Vendor.id == Product.default_vendor_id', + foreign_keys='Product.default_vendor_id', doc=""" Reference to the default :class:`Vendor` from which the product is obtained. """) + # TODO: deprecate / remove this? + vendor = orm.synonym('default_vendor') + + like_code = association_proxy( + '_like_code', 'like_code', + creator=lambda lc: ProductLikeCode(like_code=lc), + ) + + vendor_items = orm.relationship( + 'VendorItem', + back_populates='product', + primaryjoin='VendorItem.upc == Product.upc', + foreign_keys='VendorItem.upc', + order_by='VendorItem.vendor_item_id', + doc=""" + List of :class:`VendorItem` records for this product. + """) + + @property + def default_vendor_item(self): + """ + Returns the "default" vendor item record. This will + correspond to the :attr:`default_vendor` if possible. + + :rtype: :class:`VendorItem` or ``None`` + """ + if self.default_vendor: + for item in self.vendor_items: + if item.vendor_id == self.default_vendor.id: + return item + + if self.vendor_items: + return self.vendor_items[0] + @property def full_description(self): fields = ['brand', 'description', 'size'] @@ -299,11 +616,59 @@ class Product(Base): fields = filter(bool, fields) return ' '.join(fields) + @property + def complete_size(self): + """ + Returns the "complete" size string for the product. This is based on + the :attr:`size` and :attr:`unit_of_measure` attributes. + """ + parts = [self.size, self.unit_of_measure] + parts = [(part or '').strip() + for part in parts] + parts = [part for part in parts if part] + return " ".join(parts).strip() + def __str__(self): return self.description or '' -@six.python_2_unicode_compatible +class ProductLikeCode(Base): + """ + Represents the association between a product and like code. + """ + __tablename__ = 'upcLike' + __table_args__ = ( + sa.ForeignKeyConstraint(['upc'], ['products.upc']), + sa.ForeignKeyConstraint(['likeCode'], ['likeCodes.likeCode']), + ) + + upc = sa.Column(sa.String(length=13), primary_key=True, nullable=False) + product = orm.relationship( + Product, + primaryjoin=Product.upc == orm.foreign(upc), + doc=""" + Reference to the product to which this association applies. + """, + backref=orm.backref( + '_like_code', + uselist=False, + doc=""" + Reference to the like code association for the product. + """)) + + like_code_id = sa.Column('likeCode', sa.Integer(), nullable=True) + like_code = orm.relationship( + LikeCode, + doc=""" + Reference to the LikeCode to which this association applies. + """, + backref=orm.backref( + '_products', + doc=""" + List of product associations for this like code. + """)) + + class ProductFlag(Base): """ Represents a product flag attribute. @@ -320,38 +685,246 @@ class ProductFlag(Base): return self.description or '' -@six.python_2_unicode_compatible -class Employee(Base): +class ProductUser(Base): """ - Represents an employee within the organization. + Represents extended "user" info for a product (e.g. sale signage). + """ + __tablename__ = 'productUser' + + upc = sa.Column(sa.String(length=13), primary_key=True, nullable=False) + product = orm.relationship( + Product, + primaryjoin=Product.upc == upc, + foreign_keys=[upc], + doc=""" + Reference to the :class:`Product` to which this record applies. + """, + backref=orm.backref( + 'user_info', + uselist=False, + cascade='all, delete-orphan', + doc=""" + Reference to the :class:`ProductUser` record for this product, if any. + """)) + + description = sa.Column(sa.String(length=255), nullable=True) + + brand = sa.Column(sa.String(length=255), nullable=True) + + sizing = sa.Column(sa.String(length=255), nullable=True) + + photo = sa.Column(sa.String(length=255), nullable=True) + + # TODO: this was not in some older DBs + # nutrition_facts = sa.Column('nutritionFacts', sa.String(length=255), nullable=True) + + long_text = sa.Column(sa.Text(), nullable=True) + + enable_online = sa.Column('enableOnline', sa.Boolean(), nullable=True) + + sold_out = sa.Column('soldOut', sa.Boolean(), nullable=True) + + # TODO: this was not in some older DBs + # sign_count = sa.Column('signCount', sa.SmallInteger(), nullable=True, default=1) + + # TODO: this was not in some older DBs + # narrow = sa.Column(sa.Boolean(), nullable=True, default=False) + + def __str__(self): + return str(self.product or '') + + +class VendorItem(Base): + """ + Represents a "source" for a given item, from a given vendor. + """ + __tablename__ = 'vendorItems' + __table_args__ = ( + sa.ForeignKeyConstraint(['vendorID'], ['vendors.vendorID']), + ) + + sku = sa.Column(sa.String(length=13), primary_key=True, nullable=False) + + vendor_id = sa.Column('vendorID', sa.Integer(), primary_key=True, nullable=False) + vendor = orm.relationship( + Vendor, + doc=""" + Reference to the :class:`Vendor` from which the product is obtained. + """) + + # TODO: this should be autoincrement, but not primary key?? + vendor_item_id = sa.Column('vendorItemID', sa.Integer(), nullable=False) + + upc = sa.Column(sa.String(length=13), nullable=False) + product = orm.relationship( + Product, + back_populates='vendor_items', + primaryjoin=Product.upc == upc, + foreign_keys=[upc], + doc=""" + Reference to the :class:`Product` to which this record applies. + """) + + brand = sa.Column(sa.String(length=50), nullable=True) + + description = sa.Column(sa.String(length=50), nullable=True) + + size = sa.Column(sa.String(length=25), nullable=True) + + units = sa.Column(sa.Float(), nullable=True, default=1) + + cost = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True) + + sale_cost = sa.Column('saleCost', sa.Numeric(precision=10, scale=3), + nullable=True, default=0) + + vendor_department_id = sa.Column('vendorDept', sa.Integer(), nullable=True, + default=0) + + srp = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + modified = sa.Column(sa.DateTime(), nullable=True) + + def __str__(self): + return "{} from {}".format(self.sku, self.vendor) + + +class ScaleItem(Base): + """ + Represents deli scale info for a given item. + """ + __tablename__ = 'scaleItems' + + plu = sa.Column(sa.String(length=13), primary_key=True, nullable=False) + product = orm.relationship( + Product, + primaryjoin=Product.upc == plu, + foreign_keys=[plu], + doc=""" + Reference to the :class:`Product` to which this record applies. + """, + backref=orm.backref( + 'scale_item', + uselist=False, + doc=""" + Reference to the :class:`ScaleItem` record for this product. + """)) + + price = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + item_description = sa.Column('itemdesc', sa.String(length=100), nullable=True) + + exception_price = sa.Column('exceptionprice', sa.Numeric(precision=10, scale=2), nullable=True) + + weight = sa.Column(sa.SmallInteger(), nullable=True, default=0) + + by_count = sa.Column('bycount', sa.Boolean(), nullable=True, default=False) + + tare = sa.Column(sa.Float(), nullable=True, default=0) + + shelf_life = sa.Column('shelflife', sa.SmallInteger(), nullable=True, default=0) + + net_weight = sa.Column('netWeight', sa.SmallInteger(), nullable=True, default=0) + + text = sa.Column(sa.Text(), nullable=True) + + reporting_class = sa.Column('reportingClass', sa.String(length=6), nullable=True) + + label = sa.Column(sa.Integer(), nullable=True) + + graphics = sa.Column(sa.Integer(), nullable=True) + + modified = sa.Column(sa.DateTime(), nullable=True) + + # TODO: the following 3 columns are not in some older DBs; maybe need to + # figure out a "simple" way to conditionally include them? + + linked_plu = sa.Column('linkedPLU', sa.String(length=13), nullable=True) + + mosa_statement = sa.Column('mosaStatement', sa.Boolean(), nullable=True, default=False) + + origin_text = sa.Column('originText', sa.String(length=100), nullable=True) + + def __str__(self): + return str(self.product) + + +class FloorSection(Base): + """ + Represents a physical "floor section" within a store. + """ + __tablename__ = 'FloorSections' + + floorSectionID = sa.Column(sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + id = orm.synonym('floorSectionID') + + store_id = sa.Column('storeID', sa.Integer(), nullable=True, default=1) + + name = sa.Column(sa.String(length=50), nullable=True) + + # TODO: this was not in some older DBs + # map_x = sa.Column('mapX', sa.Integer(), nullable=True, default=0) + + # TODO: this was not in some older DBs + # map_y = sa.Column('mapY', sa.Integer(), nullable=True, default=0) + + # TODO: this was not in some older DBs + # map_rotate = sa.Column('mapRotate', sa.Integer(), nullable=True, default=0) + + +class ProductPhysicalLocation(Base): + """ + Represents a physical location for a product + """ + __tablename__ = 'prodPhysicalLocation' + __table_args__ = ( + sa.ForeignKeyConstraint(['floorSectionID'], ['FloorSections.floorSectionID']), + ) + + upc = sa.Column(sa.String(length=13), primary_key=True, nullable=False) + product = orm.relationship( + Product, + primaryjoin=Product.upc == upc, + foreign_keys=[upc], + doc=""" + Reference to the :class:`Product` to which this record applies. + """, + backref=orm.backref( + 'physical_location', + uselist=False, + doc=""" + Reference to the :class:`ProductPhysicalLocation` record for this + product. + """)) + + store_id = sa.Column(sa.SmallInteger(), nullable=True, default=0) + + floor_section_id = sa.Column('floorSectionID', sa.Integer(), nullable=True) + floor_section = orm.relationship( + FloorSection, + doc=""" + Reference to the :class:`FloorSection` with which this location is + associated. + """) + + section = sa.Column(sa.SmallInteger(), nullable=True, default=0) + + subsection = sa.Column(sa.SmallInteger(), nullable=True, default=0) + + shelf_set = sa.Column(sa.SmallInteger(), nullable=True, default=0) + + shelf = sa.Column(sa.SmallInteger(), nullable=True, default=0) + + location = sa.Column(sa.SmallInteger(), nullable=True, default=0) + + +class Employee(common.EmployeeBase, Base): + """ + Data model for ``employees`` table. """ __tablename__ = 'employees' - number = sa.Column('emp_no', sa.SmallInteger(), primary_key=True, autoincrement=False, nullable=False) - cashier_password = sa.Column('CashierPassword', sa.String(length=50), nullable=True) - - admin_password = sa.Column('AdminPassword', sa.String(length=50), nullable=True) - - first_name = sa.Column('FirstName', sa.String(length=255), nullable=True) - - last_name = sa.Column('LastName', sa.String(length=255), nullable=True) - - job_title = sa.Column('JobTitle', sa.String(length=255), nullable=True) - - active = sa.Column('EmpActive', sa.Boolean(), nullable=True) - - frontend_security = sa.Column('frontendsecurity', sa.SmallInteger(), nullable=True) - - backend_security = sa.Column('backendsecurity', sa.SmallInteger(), nullable=True) - - birth_date = sa.Column('birthdate', sa.DateTime(), nullable=True) - - def __str__(self): - return ' '.join([self.first_name or '', self.last_name or '']).strip() - - -@six.python_2_unicode_compatible class MemberType(Base): """ Represents a type of membership within the organization. @@ -370,20 +943,146 @@ class MemberType(Base): ssi = sa.Column(sa.Boolean(), nullable=True) - # TODO: this was apparently added "recently" - isn't present in all DBs - # (need to figure out how to conditionally include it in model?) - # sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) + # nb. this must be added explicitly if DB is new enough + #ignore_sales = sa.Column('ignoreSales', sa.Boolean(), nullable=True, default=False) + + # nb. this must be added explicitly if DB is new enough + #sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) def __str__(self): return self.description or "" -@six.python_2_unicode_compatible +class CustomerAccount(Base): + """ + This represents the customer account itself, and not a "person" per se. + + https://github.com/CORE-POS/IS4C/blob/master/fannie/classlib2.0/data/models/op/CustomerAccountsModel.php + """ + __tablename__ = 'CustomerAccounts' + + id = sa.Column('customerAccountID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + card_number = sa.Column('cardNo', sa.Integer(), nullable=True, + unique=True, index=True) + + member_status = sa.Column('memberStatus', sa.String(length=10), nullable=True, + default='PC') + + active_status = sa.Column('activeStatus', sa.String(length=10), nullable=True, + default='') + + customer_type_id = sa.Column('customerTypeID', sa.Integer(), nullable=True, + default=1) + customer_type = orm.relationship( + MemberType, + primaryjoin=MemberType.id == customer_type_id, + foreign_keys=[customer_type_id], + doc=""" + Reference to the :class:`MemberType` with which this account is associated. + """) + + charge_balance = sa.Column('chargeBalance', sa.Numeric(precision=10, scale=2), nullable=True, + default=0) + + charge_limit = sa.Column('chargeLimit', sa.Numeric(precision=10, scale=2), nullable=True, + default=0) + + id_card_upc = sa.Column('idCardUPC', sa.String(length=13), nullable=True) + + start_date = sa.Column('startDate', sa.DateTime(), nullable=True) + + end_date = sa.Column('endDate', sa.DateTime(), nullable=True) + + address_first_line = sa.Column('addressFirstLine', sa.String(length=100), nullable=True) + + address_second_line = sa.Column('addressSecondLine', sa.String(length=100), nullable=True) + + city = sa.Column(sa.String(length=50), nullable=True) + + state = sa.Column(sa.String(length=10), nullable=True) + + zip = sa.Column(sa.String(length=10), nullable=True) + + contact_allowed = sa.Column('contactAllowed', sa.Boolean(), nullable=True, + default=True) + + contact_method = sa.Column('contactMethod', sa.String(length=10), nullable=True, + default='mail') + + modified = sa.Column(sa.DateTime(), nullable=True) + + def __str__(self): + return "Account ID-{}".format(self.id) + + class Customer(Base): + """ + This really represents a "person" attached to a proper "customer account". + + https://github.com/CORE-POS/IS4C/blob/master/fannie/classlib2.0/data/models/op/CustomersModel.php + """ + __tablename__ = 'Customers' + + id = sa.Column('customerID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + account_id = sa.Column('customerAccountID', sa.Integer(), + sa.ForeignKey('CustomerAccounts.customerAccountID'), + nullable=True) + account = orm.relationship(CustomerAccount) + + card_number = sa.Column('cardNo', sa.Integer(), nullable=True) + + first_name = sa.Column('firstName', sa.String(length=50), nullable=True) + + last_name = sa.Column('lastName', sa.String(length=50), nullable=True) + + charge_allowed = sa.Column('chargeAllowed', sa.Boolean(), nullable=True, + default=True) + + checks_allowed = sa.Column('checksAllowed', sa.Boolean(), nullable=True, + default=True) + + discount = sa.Column(sa.Boolean(), nullable=True, + default=False) + + account_holder = sa.Column('accountHolder', sa.Boolean(), nullable=True, + default=False) + + staff = sa.Column(sa.Boolean(), nullable=True, + default=False) + + phone = sa.Column(sa.String(length=20), nullable=True) + + alternate_phone = sa.Column('altPhone', sa.String(length=20), nullable=True) + + email = sa.Column(sa.String(length=100), nullable=True) + + member_pricing_allowed = sa.Column('memberPricingAllowed', sa.Boolean(), nullable=True, + default=False) + + member_coupons_allowed = sa.Column('memberCouponsAllowed', sa.Boolean(), nullable=True, + default=False) + + low_income_benefits = sa.Column('lowIncomeBenefits', sa.Boolean(), nullable=True, + default=False) + + modified = sa.Column(sa.DateTime(), nullable=True) + + def __str__(self): + return "{} {}".format(self.first_name or '', self.last_name or '').strip() + + +class CustomerClassic(Base): """ Represents a customer of the organization. + + https://github.com/CORE-POS/IS4C/blob/master/fannie/classlib2.0/data/models/op/CustdataModel.php """ __tablename__ = 'custdata' + __table_args__ = ( + sa.ForeignKeyConstraint(['memType'], ['memtype.memtype']), + ) id = sa.Column(sa.Integer(), primary_key=True, autoincrement=True, nullable=False) @@ -395,15 +1094,15 @@ class Customer(Base): last_name = sa.Column('LastName', sa.String(length=30), nullable=True) - cash_back = sa.Column('CashBack', sa.Float(), nullable=False, default=60) + cash_back = sa.Column('CashBack', sa.Numeric(precision=10, scale=2), nullable=False, default=60) - balance = sa.Column('Balance', sa.Float(), nullable=False, default=0) + balance = sa.Column('Balance', sa.Numeric(precision=10, scale=2), nullable=False, default=0) discount = sa.Column('Discount', sa.SmallInteger(), nullable=True) - member_discount_limit = sa.Column('MemDiscountLimit', sa.Float(), nullable=False, default=0) + member_discount_limit = sa.Column('MemDiscountLimit', sa.Numeric(precision=10, scale=2), nullable=False, default=0) - charge_limit = sa.Column('ChargeLimit', sa.Float(), nullable=False, default=0) + charge_limit = sa.Column('ChargeLimit', sa.Numeric(precision=10, scale=2), nullable=False, default=0) charge_ok = sa.Column('ChargeOk', sa.Boolean(), nullable=False, default=False) @@ -426,7 +1125,7 @@ class Customer(Base): ssi = sa.Column('SSI', sa.Boolean(), nullable=False, default=False) - purchases = sa.Column('Purchases', sa.Float(), nullable=False, default=0) + purchases = sa.Column('Purchases', sa.Numeric(precision=10, scale=2), nullable=False, default=0) number_of_checks = sa.Column('NumberOfChecks', sa.SmallInteger(), nullable=False, default=0) @@ -440,7 +1139,7 @@ class Customer(Base): member_info = orm.relationship( 'MemberInfo', - primaryjoin='MemberInfo.card_number == Customer.card_number', + primaryjoin='MemberInfo.card_number == CustomerClassic.card_number', foreign_keys=[card_number], uselist=False, back_populates='customers', @@ -452,7 +1151,10 @@ class Customer(Base): return "{} {}".format(self.first_name or '', self.last_name or '').strip() -@six.python_2_unicode_compatible +# TODO: deprecate / remove this +CustData = CustomerClassic + + class MemberInfo(Base): """ Contact info regarding a member of the organization. @@ -461,14 +1163,14 @@ class MemberInfo(Base): card_number = sa.Column('card_no', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) - last_name = sa.Column(sa.String(length=30), nullable=True) - first_name = sa.Column(sa.String(length=30), nullable=True) - other_last_name = sa.Column('othlast_name', sa.String(length=30), nullable=True) + last_name = sa.Column(sa.String(length=30), nullable=True) other_first_name = sa.Column('othfirst_name', sa.String(length=30), nullable=True) + other_last_name = sa.Column('othlast_name', sa.String(length=30), nullable=True) + street = sa.Column(sa.String(length=255), nullable=True) city = sa.Column(sa.String(length=20), nullable=True) @@ -481,18 +1183,22 @@ class MemberInfo(Base): email = sa.Column('email_1', sa.String(length=50), nullable=True) - email2 = sa.Column('email_2', sa.String(length=50), nullable=True) + email2 = sa.Column('email_2', sa.String(length=50), nullable=True, doc=""" + NB. this is labeled "Alt. Phone" in CORE Office member view, and + is named `altPhone` when dealing with CORE Office webservices API. + """) ads_ok = sa.Column('ads_OK', sa.Boolean(), nullable=True, default=True) customers = orm.relationship( - Customer, - primaryjoin=Customer.card_number == card_number, - foreign_keys=[Customer.card_number], + CustomerClassic, + primaryjoin=CustomerClassic.card_number == card_number, + order_by=CustomerClassic.person_number, + foreign_keys=[CustomerClassic.card_number], back_populates='member_info', - remote_side=Customer.card_number, + remote_side=CustomerClassic.card_number, doc=""" - List of :class:`Customer` instances which are associated with this member info. + List of :class:`CustomerClassic` instances which are associated with this member info. """) dates = orm.relationship( @@ -500,6 +1206,7 @@ class MemberInfo(Base): primaryjoin='MemberDate.card_number == MemberInfo.card_number', foreign_keys='MemberDate.card_number', cascade='all, delete-orphan', + uselist=False, doc=""" List of date records for the member. """, @@ -509,15 +1216,79 @@ class MemberInfo(Base): Reference to the member to whom the date record applies. """)) + barcodes = orm.relationship( + 'MemberBarcode', + primaryjoin='MemberBarcode.card_number == MemberInfo.card_number', + foreign_keys='MemberBarcode.card_number', + order_by='MemberBarcode.upc', + # cascade='all, delete-orphan', + doc=""" + List of extra barcode records for the member. + """, + backref=orm.backref( + 'member_info', + doc=""" + Reference to the :class:`MemberInfo` record to which the barcode applies. + """)) + + notes = orm.relationship( + 'MemberNote', + primaryjoin='MemberNote.card_number == MemberInfo.card_number', + foreign_keys='MemberNote.card_number', + order_by='MemberNote.timestamp', + cascade='all, delete-orphan', + doc=""" + List of note records for the member. + """, + backref=orm.backref( + 'member_info', + doc=""" + Reference to the :class:`MemberInfo` record to which the note applies. + """)) + + suspension = orm.relationship( + 'Suspension', + primaryjoin='Suspension.card_number == MemberInfo.card_number', + foreign_keys='Suspension.card_number', + uselist=False, + doc=""" + Suspension record for the member, if applicable. + """, + backref=orm.backref( + 'member_info', + doc=""" + Reference to the :class:`MemberInfo` record to which the suspension + applies. + """)) + @property def full_name(self): return '{} {}'.format(self.first_name or '', self.last_name or '').strip() def __str__(self): - return self.full_name + name = self.full_name + if name: + return name + return "Member Info #{}".format(self.card_number) + + def split_street(self): + """ + Tries to split the :attr:`street` attribute into 2 separate lines, e.g. + "street1" and "street2" style. Always returns a 2-tuple even if the + second line would be empty. + """ + address = (self.street or '').strip() + lines = address.split('\n') + street1 = lines[0].strip() or None + street2 = None + if len(lines) > 1: + street2 = lines[1].strip() or None + if len(lines) > 2: + log.warning("member #%s has %s address lines: %s", + self.card_number, len(lines), self) + return (street1, street2) -@six.python_2_unicode_compatible class MemberDate(Base): """ Join/exit dates for members @@ -536,10 +1307,9 @@ class MemberDate(Base): self.end_date.date() if self.end_date else "??") -@six.python_2_unicode_compatible class MemberContact(Base): """ - Contact preferences for members + Member contacts """ __tablename__ = 'memContact' @@ -566,7 +1336,123 @@ class MemberContact(Base): return str(self.preference) -@six.python_2_unicode_compatible +class MemberContactPreference(Base): + """ + Member contact preferences + """ + __tablename__ = 'memContactPrefs' + + id = sa.Column('pref_id', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + description = sa.Column('pref_description', sa.String(length=50), nullable=True) + + def __str__(self): + return self.description or "" + + +class MemberBarcode(Base): + """ + Additional barcode for a member. + """ + __tablename__ = 'memberCards' + + card_number = sa.Column('card_no', sa.Integer(), nullable=False, + primary_key=True, autoincrement=False) + + upc = sa.Column(sa.String(length=13), nullable=False, primary_key=True) + + def __str__(self): + return self.upc or "" + + +class MemberNote(Base): + """ + Additional notes for a member. + """ + __tablename__ = 'memberNotes' + + id = sa.Column('memberNoteID', sa.Integer(), nullable=False, primary_key=True, autoincrement=True) + + card_number = sa.Column('cardno', sa.Integer(), nullable=True) + + note = sa.Column(sa.Text(), nullable=True) + + timestamp = sa.Column('stamp', sa.DateTime(), nullable=True) + + username = sa.Column(sa.String(length=50), nullable=True) + + def __str__(self): + return self.note or "" + + +class CustomerNotification(Base): + """ + Represents a customer notification for display at the lane. + + https://github.com/CORE-POS/IS4C/blob/master/fannie/classlib2.0/data/models/op/CustomerNotificationsModel.php + """ + __tablename__ = 'CustomerNotifications' + + id = sa.Column('customerNotificationID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + card_number = sa.Column('cardNo', sa.Integer(), nullable=True) + customer_id = sa.Column('customerID', sa.Integer(), nullable=True) + source = sa.Column(sa.String(length=50), nullable=True) + type = sa.Column(sa.String(length=50), nullable=True) + message = sa.Column(sa.String(length=255), nullable=True) + modifier_module = sa.Column('modifierModule', sa.String(length=50), nullable=True) + + def __str__(self): + return self.message or '' + + +class ReasonCode(Base): + """ + Reason codes for legacy account suspensions. + """ + __tablename__ = 'reasoncodes' + + mask = sa.Column(sa.Integer(), nullable=False, primary_key=True, autoincrement=False) + + text_string = sa.Column('textStr', sa.String(length=100), nullable=True) + + def __str__(self): + return "#{}: {}".format(self.mask, self.text_string) + + +class Suspension(Base): + """ + Suspension status for legacy customer accounts. + """ + __tablename__ = 'suspensions' + __table_args__ = ( + sa.ForeignKeyConstraint(['reasoncode'], ['reasoncodes.mask']), + ) + + card_number = sa.Column('cardno', sa.Integer(), nullable=False, primary_key=True, autoincrement=False) + + type = sa.Column(sa.String(length=1), nullable=True) + + memtype1 = sa.Column(sa.Integer(), nullable=True) + + memtype2 = sa.Column(sa.String(length=6), nullable=True) + + suspension_date = sa.Column('suspDate', sa.DateTime(), nullable=True) + + reason = sa.Column(sa.Text(), nullable=True) + + mail_flag = sa.Column('mailflag', sa.Integer(), nullable=True) + + discount = sa.Column(sa.Integer(), nullable=True) + + charge_limit = sa.Column('chargelimit', sa.Numeric(precision=10, scale=2), nullable=True) + + reason_code = sa.Column('reasoncode', sa.Integer(), nullable=True) + reason_object = orm.relationship(ReasonCode) + + def __str__(self): + return "#{} on {}".format(self.card_number, + self.suspension_date) + + class HouseCoupon(Base): """ Represents a "house" (store) coupon. @@ -606,3 +1492,275 @@ class HouseCoupon(Base): def __str__(self): return self.description or '' + + +class Tender(Base): + """ + Represents a tender for payment at POS + """ + __tablename__ = 'tenders' + + tender_id = sa.Column('TenderID', sa.Integer(), primary_key=True, nullable=False) + + tender_code = sa.Column('TenderCode', sa.String(length=2), nullable=True) + tender_name = sa.Column('TenderName', sa.String(length=25), nullable=True) + tender_type = sa.Column('TenderType', sa.String(length=2), nullable=True) + change_message = sa.Column('ChangeMessage', sa.String(length=25), nullable=True) + min_amount = sa.Column('MinAmount', sa.Numeric(precision=10, scale=2), nullable=True) + max_amount = sa.Column('MaxAmount', sa.Numeric(precision=10, scale=2), nullable=True) + max_refund = sa.Column('MaxRefund', sa.Numeric(precision=10, scale=2), nullable=True) + tender_module = sa.Column('TenderModule', sa.String(length=50), nullable=True) + sales_code = sa.Column('SalesCode', sa.Integer(), nullable=True) + + def __str__(self): + return self.tender_name or '' + + +class CustomReceiptLine(Base): + """ + Represents a "text string" line for a custom receipt. + """ + __tablename__ = 'customReceipt' + + # nb. this should be autoincrement, but we can't do that + # automatically via sqlalchemy when PK is composite + sequence = sa.Column('seq', sa.Integer(), primary_key=True, nullable=False) + + type = sa.Column(sa.String(length=20), primary_key=True, nullable=False) + text = sa.Column(sa.String(length=80), nullable=True) + + def __str__(self): + return self.text or "" + + +class BatchType(Base): + """ + Represents the definition of a batch type. + """ + __tablename__ = 'batchType' + + # nb. this is *not* autoincrement for some reason; must + # calculate new ID manually based on max existing + id = sa.Column('batchTypeID', sa.Integer(), nullable=False, primary_key=True, autoincrement=False) + + description = sa.Column('typeDesc', sa.String(length=50), nullable=True) + + discount_type = sa.Column('discType', sa.Integer(), nullable=True) + + dated_signs = sa.Column('datedSigns', sa.Boolean(), nullable=True, default=True) + + special_order_eligible = sa.Column('specialOrderEligible', sa.Boolean(), nullable=True, default=True) + + editor_ui = sa.Column('editorUI', sa.SmallInteger(), nullable=True, default=True) + + allow_single_store = sa.Column('allowSingleStore', sa.Boolean(), nullable=True, default=False) + + exit_inventory = sa.Column('exitInventory', sa.Boolean(), nullable=True, default=False) + + def __str__(self): + return self.description or "" + + +class Batch(Base): + """ + Represents a batch. + """ + __tablename__ = 'batches' + + id = sa.Column('batchID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + start_date = sa.Column('startDate', sa.DateTime(), nullable=True) + + end_date = sa.Column('endDate', sa.DateTime(), nullable=True) + + name = sa.Column('batchName', sa.String(length=80), nullable=True) + + batch_type_id = sa.Column('batchType', sa.Integer(), + sa.ForeignKey('batchType.batchTypeID'), nullable=True) + batch_type = orm.relationship(BatchType) + + discount_type = sa.Column('discountType', sa.Integer(), nullable=True) + + priority = sa.Column(sa.Integer(), nullable=True) + + owner = sa.Column(sa.String(length=50), nullable=True) + + trans_limit = sa.Column('transLimit', sa.Boolean(), nullable=True, default=False) + + notes = sa.Column(sa.Text(), nullable=True) + + def __str__(self): + return self.name or "" + + +class BatchItem(Base): + """ + Represents a batch "list" item. + """ + __tablename__ = 'batchList' + + id = sa.Column('listID', sa.Integer(), primary_key=True, autoincrement=True, nullable=False) + + batch_id = sa.Column('batchID', sa.Integer(), + sa.ForeignKey('batches.batchID'), nullable=True) + batch = orm.relationship(Batch, backref=orm.backref('items')) + + upc = sa.Column(sa.String(length=13), nullable=True) + product = orm.relationship( + Product, + primaryjoin=Product.upc == upc, + foreign_keys=[upc]) + + sale_price = sa.Column('salePrice', sa.Numeric(precision=9, scale=3), nullable=True) + + group_sale_price = sa.Column('groupSalePrice', sa.Numeric(precision=9, scale=3), nullable=True) + + active = sa.Column(sa.Boolean(), nullable=True) + + price_method = sa.Column('pricemethod', sa.Integer(), nullable=True, default=0) + + quantity = sa.Column(sa.Integer(), nullable=True, default=0) + + sign_multiplier = sa.Column('signMultiplier', sa.Boolean(), nullable=True, default=True) + + cost = sa.Column(sa.Numeric(precision=9, scale=3), nullable=True, default=0) + + def __str__(self): + return self.upc or "" + + +class PurchaseOrder(Base): + """ + Represents a purchase order. + """ + __tablename__ = 'PurchaseOrder' + # TODO: would be simpler to declare these, but is it safe? + # __table_args__ = ( + # sa.ForeignKeyConstraint(['vendorID'], ['vendors.vendorID']), + # sa.ForeignKeyConstraint(['storeID'], ['Stores.storeID']), + # ) + + orderID = sa.Column(sa.Integer(), nullable=False, primary_key=True, autoincrement=True) + id = orm.synonym('orderID') + + vendor_id = sa.Column('vendorID', sa.Integer(), nullable=True) + vendor = orm.relationship( + Vendor, + primaryjoin=Vendor.id == vendor_id, + foreign_keys=[vendor_id]) + + store_id = sa.Column('storeID', sa.Integer(), nullable=True) + store = orm.relationship( + Store, + primaryjoin=Store.id == store_id, + foreign_keys=[store_id]) + + creation_date = sa.Column('creationDate', sa.DateTime(), nullable=True) + + placed = sa.Column(sa.Boolean(), nullable=True, default=False) + + placed_date = sa.Column('placedDate', sa.DateTime(), nullable=True) + + user_id = sa.Column('userID', sa.Integer(), nullable=True) + + vendor_order_id = sa.Column('vendorOrderID', sa.String(length=25), nullable=True) + + vendor_invoice_id = sa.Column('vendorInvoiceID', sa.String(length=25), nullable=True) + + standing_id = sa.Column('standingID', sa.Integer(), nullable=True) + + inventory_ignore = sa.Column('inventoryIgnore', sa.Boolean(), nullable=True, default=False) + + transfer_id = sa.Column('transferID', sa.Integer(), nullable=True) + + notes = association_proxy('notes', 'notes') + + def __str__(self): + return "#{} for {}".format(self.id, self.vendor or "??") + + +class PurchaseOrderItem(Base): + """ + Represents a line item in a purchase order. + """ + __tablename__ = 'PurchaseOrderItems' + __table_args__ = ( + sa.ForeignKeyConstraint(['orderID'], ['PurchaseOrder.orderID']), + ) + + order_id = sa.Column('orderID', sa.Integer(), nullable=False, + primary_key=True, autoincrement=False) + order = orm.relationship(PurchaseOrder, backref=orm.backref('items')) + + sku = sa.Column(sa.String(length=13), nullable=False, + primary_key=True) + + quantity = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + unit_cost = sa.Column('unitCost', sa.Numeric(precision=10, scale=4), nullable=True) + + case_size = sa.Column('caseSize', sa.Float(), nullable=True) + + received_date = sa.Column('receivedDate', sa.DateTime(), nullable=True) + + received_quantity = sa.Column('receivedQty', sa.Float(), nullable=True) + + received_total_cost = sa.Column('receivedTotalCost', sa.Numeric(precision=10, scale=4), nullable=True) + + unit_size = sa.Column('unitSize', sa.String(length=25), nullable=True) + + brand = sa.Column(sa.String(length=50), nullable=True) + + description = sa.Column(sa.String(length=50), nullable=True) + + internal_upc = sa.Column('internalUPC', sa.String(length=13), nullable=True) + + sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) + + is_special_order = sa.Column('isSpecialOrder', sa.Boolean(), nullable=True, + default=False) + + # TODO: this probably is FK to e.g. User.id ? + received_by = sa.Column('receivedBy', sa.Integer(), nullable=True, + default=0) + + def __str__(self): + return self.description or "" + + +class PurchaseOrderNote(Base): + """ + Represents a note attached to a purchase order. + """ + __tablename__ = 'PurchaseOrderNotes' + __table_args__ = ( + sa.ForeignKeyConstraint(['orderID'], ['PurchaseOrder.orderID']), + ) + + order_id = sa.Column('orderID', sa.Integer(), nullable=False, + primary_key=True, autoincrement=False) + order = orm.relationship(PurchaseOrder, backref=orm.backref('notes')) + + notes = sa.Column(sa.Text(), nullable=True) + + def __str__(self): + return self.notes or "" + + +# the rest of this is a workaround to deal with the fact that some +# CORE databases have columns which others do not. i had assumed that +# all would be more or less the same but not so in practice. so if +# your DB *does* have these columns, you must invoke the function +# below in order to merge them into your schema. you should do this +# on app startup and they'll be available normally from then on. + +RUNTIME = {'added_latest_columns': False} + +def use_latest_columns(): + if RUNTIME['added_latest_columns']: + return + + MemberType.ignore_sales = sa.Column('ignoreSales', sa.Boolean(), nullable=True, default=False) + MemberType.sales_code = sa.Column('salesCode', sa.Integer(), nullable=True) + + RUNTIME['added_latest_columns'] = True diff --git a/corepos/db/office_trans/model.py b/corepos/db/office_trans/model.py index 55aff86..c2b0959 100644 --- a/corepos/db/office_trans/model.py +++ b/corepos/db/office_trans/model.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar +# Copyright © 2018-2025 Lance Edgar # # This file is part of pyCOREPOS. # @@ -24,102 +24,68 @@ CORE POS Transaction Data Model """ -from __future__ import unicode_literals, absolute_import - -import six import sqlalchemy as sa -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import orm + +from corepos.db.common import trans as common -Base = declarative_base() +Base = orm.declarative_base() -@six.python_2_unicode_compatible -class TransactionDetail(Base): +# TODO: not sure what primary key should be for this? am trying a +# composite one so far, we'll see...cf. also andy's comments in +# https://github.com/CORE-POS/IS4C/pull/1189#issuecomment-1597481138 +class StockPurchase(Base): """ - Represents a POS transaction detail record. + Represents a member equity payment. + """ + __tablename__ = 'stockpurchases' + + card_number = sa.Column('card_no', sa.Integer(), nullable=False, primary_key=True, autoincrement=False) + + amount = sa.Column('stockPurchase', sa.Numeric(precision=10, scale=2), nullable=True) + + datetime = sa.Column('tdate', sa.DateTime(), nullable=True, primary_key=True, autoincrement=False) + + transaction_number = sa.Column('trans_num', sa.String(length=50), nullable=True, primary_key=True) + + transaction_id = sa.Column('trans_id', sa.Integer(), nullable=True) + + department_number = sa.Column('dept', sa.Integer(), nullable=True, primary_key=True, autoincrement=False) + + def __str__(self): + return f"#{self.card_number} for ${self.amount}" + + +class EquityLiveBalance(Base): + + __tablename__ = 'equity_live_balance' + + member_number = sa.Column('memnum', sa.Integer(), nullable=False, primary_key=True, autoincrement=False) + + payments = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) + + start_date = sa.Column('startdate', sa.DateTime(), nullable=True) + + +class DTransactionBase(common.TransactionDetailBase): + """ + Base class for ``dtransactions`` and similar models. + """ + store_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) + + pos_row_id = sa.Column(sa.Integer(), nullable=True) + store_id = sa.Column(sa.Integer(), nullable=True, default=0) + date_time = sa.Column('datetime', sa.DateTime(), nullable=True) + + +class DTransaction(DTransactionBase, Base): + """ + Data model for ``dtransactions`` table. """ __tablename__ = 'dtransactions' - # store - store_row_id = sa.Column(sa.Integer(), primary_key=True, nullable=False) - store_id = sa.Column(sa.Integer(), nullable=True, default=0) - # register - register_number = sa.Column('register_no', sa.Integer(), nullable=True) - pos_row_id = sa.Column(sa.Integer(), nullable=True) - - # txn - transaction_id = sa.Column('trans_id', sa.Integer(), nullable=True) - transaction_number = sa.Column('trans_no', sa.Integer(), nullable=True) - transaction_type = sa.Column('trans_type', sa.String(length=1), nullable=True) - transaction_subtype = sa.Column('trans_subtype', sa.String(length=2), nullable=True) - transaction_status = sa.Column('trans_status', sa.String(length=1), nullable=True) - - # timestamps - date_time = sa.Column('datetime', sa.DateTime(), nullable=True) - - # cashier - employee_number = sa.Column('emp_no', sa.Integer(), nullable=True) - - # customer - card_number = sa.Column('card_no', sa.Integer(), nullable=True) - member_type = sa.Column('memType', sa.Integer(), nullable=True) - staff = sa.Column(sa.Boolean(), nullable=True) - - ############################## - # remainder is "line item" ... - ############################## - - upc = sa.Column(sa.String(length=13), nullable=True) - - department_number = sa.Column('department', sa.Integer(), nullable=True) - - description = sa.Column(sa.String(length=30), nullable=True) - - quantity = sa.Column(sa.Float(), nullable=True) - - scale = sa.Column(sa.Boolean(), nullable=True, default=False) - - cost = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) - - unit_price = sa.Column('unitPrice', sa.Numeric(precision=10, scale=2), nullable=True) - - total = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) - - reg_price = sa.Column('regPrice', sa.Numeric(precision=10, scale=2), nullable=True) - - tax = sa.Column(sa.Boolean(), nullable=True) - - food_stamp = sa.Column('foodstamp', sa.Boolean(), nullable=True) - - discount = sa.Column(sa.Numeric(precision=10, scale=2), nullable=True) - - member_discount = sa.Column('memDiscount', sa.Numeric(precision=10, scale=2), nullable=True) - - discountable = sa.Column(sa.Boolean(), nullable=True) - - discount_type = sa.Column('discounttype', sa.Integer(), nullable=True) - - voided = sa.Column(sa.Boolean(), nullable=True) - - percent_discount = sa.Column('percentDiscount', sa.Integer(), nullable=True) - - item_quantity = sa.Column('ItemQtty', sa.Float(), nullable=True) - - volume_discount_type = sa.Column('volDiscType', sa.Integer(), nullable=True) - - volume = sa.Column(sa.Integer(), nullable=True) - - volume_special = sa.Column('VolSpecial', sa.Numeric(precision=10, scale=2), nullable=True) - - mix_match = sa.Column('mixMatch', sa.String(length=13), nullable=True) - - matched = sa.Column(sa.Boolean(), nullable=True) - - num_flag = sa.Column('numflag', sa.Integer(), nullable=True, default=0) - - char_flag = sa.Column('charflag', sa.String(length=2), nullable=True) - - def __str__(self): - return self.description or '' +# TODO: deprecate / remove this +TransactionDetail = DTransaction diff --git a/corepos/db/office_trans_archive/__init__.py b/corepos/db/office_trans_archive/__init__.py new file mode 100644 index 0000000..6ddb31d --- /dev/null +++ b/corepos/db/office_trans_archive/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2023 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +"Archive" Transaction Database Interface +""" + +import warnings +warnings.warn("The `corepos.db.office_trans_archive` module is deprecated! " + "Please use `corepos.db.office_arch` instead.", + DeprecationWarning, stacklevel=2) + +from corepos.db.office_arch import * diff --git a/corepos/db/office_trans_archive/model.py b/corepos/db/office_trans_archive/model.py new file mode 100644 index 0000000..e57d071 --- /dev/null +++ b/corepos/db/office_trans_archive/model.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2023 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +CORE POS Transaction Data Model +""" + +import warnings +warnings.warn("The `corepos.db.office_trans_archive.model` module is deprecated! " + "Please use `corepos.db.office_arch.model` instead.", + DeprecationWarning, stacklevel=2) + +from corepos.db.office_arch.model import * diff --git a/corepos/db/util.py b/corepos/db/util.py index 582aa17..f16da72 100644 --- a/corepos/db/util.py +++ b/corepos/db/util.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2019 Lance Edgar +# Copyright © 2018-2020 Lance Edgar # # This file is part of pyCOREPOS. # @@ -24,11 +24,9 @@ CORE POS Database Utilities """ -from __future__ import unicode_literals, absolute_import - import sqlalchemy as sa -from corepos.db import model as corepos +from corepos.db.office_op import model as corepos def get_last_card_number(session): @@ -38,3 +36,25 @@ def get_last_card_number(session): """ return session.query(sa.func.max(corepos.Customer.card_number))\ .scalar() or 0 + + +def table_exists(session, model_class): + """ + Determine if a table exists in the database. + + :param session: SQLAlchemy session object, opened against the database in + question. + + :param model_class: The model class associated with the table in question. + + :returns: Boolean indicating if the table exists. + """ + try: + session.query(model_class).count() + except sa.exc.ProgrammingError as error: + if "doesn't exist" in str(error): + return False + else: + raise + else: + return True diff --git a/corepos/enum.py b/corepos/enum.py index 62c16ac..93780c3 100644 --- a/corepos/enum.py +++ b/corepos/enum.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2019 Lance Edgar +# Copyright © 2018-2024 Lance Edgar # # This file is part of pyCOREPOS. # @@ -24,12 +24,44 @@ CORE POS enumeration constants """ -from __future__ import unicode_literals, absolute_import +from collections import OrderedDict +from enum import Enum -try: - from collections import OrderedDict -except ImportError: - from ordereddict import OrderedDict + +class CoreDbType(str, Enum): + office_op = 'office_op' + office_trans = 'office_trans' + office_arch = 'office_arch' + + +BATCH_DISCOUNT_TYPE_TRACKING = -1 +BATCH_DISCOUNT_TYPE_PRICE_CHANGE = 0 +BATCH_DISCOUNT_TYPE_SALE_EVERYONE = 1 +BATCH_DISCOUNT_TYPE_SALE_RESTRICTED = 2 +BATCH_DISCOUNT_TYPE_SLIDING_PERCENT = 3 +BATCH_DISCOUNT_TYPE_SLIDING_AMOUNT = 5 + +BATCH_DISCOUNT_TYPE = OrderedDict([ + (BATCH_DISCOUNT_TYPE_PRICE_CHANGE, "None (Change regular price)"), + (BATCH_DISCOUNT_TYPE_SALE_EVERYONE, "Sale for everyone"), + (BATCH_DISCOUNT_TYPE_SALE_RESTRICTED, "Sale for Members"), + (BATCH_DISCOUNT_TYPE_SLIDING_PERCENT, "Sliding % Off for Members"), + (BATCH_DISCOUNT_TYPE_SLIDING_AMOUNT, "Sliding $ Off for Members"), + (BATCH_DISCOUNT_TYPE_TRACKING, "Tracking (does not change any prices)"), +]) + + +BATCH_EDITOR_UI_STANDARD = 1 +BATCH_EDITOR_UI_PAIRED_SALE = 2 +BATCH_EDITOR_UI_PARTIAL = 3 +BATCH_EDITOR_UI_TRACKING = 4 + +BATCH_EDITOR_UI = OrderedDict([ + (BATCH_EDITOR_UI_STANDARD, "Standard"), + (BATCH_EDITOR_UI_PAIRED_SALE, "Paired Sale"), + (BATCH_EDITOR_UI_PARTIAL, "Partial"), + (BATCH_EDITOR_UI_TRACKING, "Tracking"), +]) HOUSE_COUPON_MEMBER_ONLY_NO = 0 @@ -92,3 +124,14 @@ MEMBER_CONTACT_PREFERENCE = OrderedDict([ (MEMBER_CONTACT_PREFERENCE_EMAIL_ONLY, "email only"), (MEMBER_CONTACT_PREFERENCE_BOTH, "both (postal mail and email)"), ]) + + +PRODUCT_PRICE_METHOD_DISABLED = 0 +PRODUCT_PRICE_METHOD_ALWAYS = 1 +PRODUCT_PRICE_METHOD_FULL_SETS = 2 + +PRODUCT_PRICE_METHOD = OrderedDict([ + (PRODUCT_PRICE_METHOD_DISABLED, "Disabled"), + (PRODUCT_PRICE_METHOD_ALWAYS, "Always use this price"), + (PRODUCT_PRICE_METHOD_FULL_SETS, "Use this price for full sets"), +]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cf50f41 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "pyCOREPOS" +version = "0.5.1" +description = "Python Interface to CORE POS" +readme = "README.md" +authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] +license = {text = "GNU GPL v3+"} +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Office/Business", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +dependencies = [ + "mysql-connector-python", + "requests", + "SQLAlchemy>=1.4", +] + + +[project.urls] +Homepage = "https://forgejo.wuttaproject.org/rattail/pycorepos" +Repository = "https://forgejo.wuttaproject.org/rattail/pycorepos" +Issues = "https://forgejo.wuttaproject.org/rattail/pycorepos/issues" +Changelog = "https://forgejo.wuttaproject.org/rattail/pycorepos/src/branch/master/CHANGELOG.md" + + +[tool.commitizen] +version_provider = "pep621" +tag_format = "v$version" +update_changelog_on_bump = true + + +[tool.hatch.build.targets.wheel] +packages = ["corepos"] diff --git a/setup.py b/setup.py deleted file mode 100644 index d9afeec..0000000 --- a/setup.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018-2020 Lance Edgar -# -# This file is part of pyCOREPOS. -# -# pyCOREPOS is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# pyCOREPOS. If not, see . -# -################################################################################ - -import os -import sys -from setuptools import setup, find_packages - - -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, 'corepos', '_version.py')).read()) -README = open(os.path.join(here, 'README.rst')).read() - - -requires = [ - # - # Version numbers within comments below have specific meanings. - # Basically the 'low' value is a "soft low," and 'high' a "soft high." - # In other words: - # - # If either a 'low' or 'high' value exists, the primary point to be - # made about the value is that it represents the most current (stable) - # version available for the package (assuming typical public access - # methods) whenever this project was started and/or documented. - # Therefore: - # - # If a 'low' version is present, you should know that attempts to use - # versions of the package significantly older than the 'low' version - # may not yield happy results. (A "hard" high limit may or may not be - # indicated by a true version requirement.) - # - # Similarly, if a 'high' version is present, and especially if this - # project has laid dormant for a while, you may need to refactor a bit - # when attempting to support a more recent version of the package. (A - # "hard" low limit should be indicated by a true version requirement - # when a 'high' version is present.) - # - # In any case, developers and other users are encouraged to play - # outside the lines with regard to these soft limits. If bugs are - # encountered then they should be filed as such. - # - # package # low high - - 'mysql-connector-python', # 8.0.6 - 'six', # 1.12.0 - 'SQLAlchemy', # 0.9.8 -] - - -setup( - name = "pyCOREPOS", - version = __version__, - author = "Lance Edgar", - author_email = "lance@edbob.org", - url = "https://rattailproject.org/", - license = "GNU GPL v3", - description = "Python Interface to CORE POS", - long_description = README, - - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Environment :: Console', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Topic :: Office/Business', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - - install_requires = requires, - packages = find_packages(), -) diff --git a/tasks.py b/tasks.py index 38d2267..6946a7f 100644 --- a/tasks.py +++ b/tasks.py @@ -2,7 +2,7 @@ ################################################################################ # # pyCOREPOS -- Python Interface to CORE POS -# Copyright © 2018 Lance Edgar +# Copyright © 2018-2024 Lance Edgar # # This file is part of pyCOREPOS. # @@ -24,17 +24,33 @@ Tasks for 'pyCOREPOS' package """ -from __future__ import unicode_literals, absolute_import - +import os +import re import shutil from invoke import task +here = os.path.abspath(os.path.dirname(__file__)) +__version__ = None +pattern = re.compile(r'^version = "(\d+\.\d+\.\d+)"$') +with open(os.path.join(here, 'pyproject.toml'), 'rt') as f: + for line in f: + line = line.rstrip('\n') + match = pattern.match(line) + if match: + __version__ = match.group(1) + break +if not __version__: + raise RuntimeError("could not parse version!") + + @task -def release(ctx): +def release(c): """ Release a new version of 'pyCOREPOS'. """ - shutil.rmtree('pyCOREPOS.egg-info') - ctx.run('python setup.py sdist --formats=gztar upload') + if os.path.exists('pyCOREPOS.egg-info'): + shutil.rmtree('pyCOREPOS.egg-info') + c.run('python -m build --sdist') + c.run('twine upload dist/pycorepos-{}.tar.gz'.format(__version__))