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__))