diff --git a/src/wuttapos/app.py b/src/wuttapos/app.py
index 7ede529..a3f9d2e 100644
--- a/src/wuttapos/app.py
+++ b/src/wuttapos/app.py
@@ -32,19 +32,65 @@ class WuttaPosAppProvider(base.AppProvider):
Custom :term:`app provider` for WuttaPOS.
"""
+ default_people_handler_spec = "wuttapos.people:PeopleHandler"
+ default_employment_handler_spec = "wuttapos.employment:EmploymentHandler"
+ default_clientele_handler_spec = "wuttapos.clientele:ClienteleHandler"
+ default_products_handler_spec = "wuttapos.products:ProductsHandler"
+
email_templates = ["wuttapos:email-templates"]
- def get_transaction_handler(self):
+ def get_clientele_handler(self):
"""
- Get the configured :term:`transaction handler`.
+ Get the configured "clientele" :term:`handler`.
- :rtype: :class:`~wuttapos.handler.TransactionHandler`
+ :rtype: :class:`~wuttapos.clientele.ClienteleHandler`
"""
- if "transaction" not in self.app.handlers:
+ if "clientele" not in self.app.handlers:
spec = self.config.get(
- "wuttapos.transaction_handler",
- default="wuttapos.handler:TransactionHandler",
+ f"{self.appname}.clientele.handler",
+ default=self.default_clientele_handler_spec,
)
factory = self.app.load_object(spec)
- self.app.handlers["transaction"] = factory(self.config)
- return self.app.handlers["transaction"]
+ self.app.handlers["clientele"] = factory(self.config)
+ return self.app.handlers["clientele"]
+
+ def get_employment_handler(self):
+ """
+ Get the configured "employment" :term:`handler`.
+
+ :rtype: :class:`~wuttapos.employment.EmploymentHandler`
+ """
+ if "employment" not in self.app.handlers:
+ spec = self.config.get(
+ f"{self.appname}.employment.handler",
+ default=self.default_employment_handler_spec,
+ )
+ factory = self.app.load_object(spec)
+ self.app.handlers["employment"] = factory(self.config)
+ return self.app.handlers["employment"]
+
+ def get_products_handler(self):
+ """
+ Get the configured "products" :term:`handler`.
+
+ :rtype: :class:`~wuttapos.products.ProductsHandler`
+ """
+ if "products" not in self.app.handlers:
+ spec = self.config.get(
+ f"{self.appname}.products.handler",
+ default=self.default_products_handler_spec,
+ )
+ factory = self.app.load_object(spec)
+ self.app.handlers["products"] = factory(self.config)
+ return self.app.handlers["products"]
+
+ def get_employee(self, obj):
+ """
+ Convenience method to locate a
+ :class:`~wuttjamaican.db.model.base.Person` for the given
+ object.
+
+ This delegates to the "people" handler method,
+ :meth:`~wuttjamaican.people.PeopleHandler.get_person()`.
+ """
+ return self.get_employment_handler().get_employee(obj)
diff --git a/src/wuttapos/batch/__init__.py b/src/wuttapos/batch/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/wuttapos/batch/pos.py b/src/wuttapos/batch/pos.py
new file mode 100644
index 0000000..74a285a
--- /dev/null
+++ b/src/wuttapos/batch/pos.py
@@ -0,0 +1,747 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+POS batch handler
+"""
+
+# import decimal
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.batch import BatchHandler
+
+from wuttapos.db.model import POSBatch
+
+
+class POSBatchHandler(BatchHandler):
+ """
+ Handler for POS batches
+ """
+
+ model_class = POSBatch
+
+ def get_store_id(self, require=False):
+ """
+ Returns the ID string for store to which local terminal belongs.
+ """
+ return self.config.get("wuttapos.store_id", require=require)
+
+ def get_terminal_id(self, require=False):
+ """
+ Returns the ID string for current POS terminal.
+ """
+ return self.config.get("wuttapos.terminal_id", require=require)
+
+ def init_batch(self, batch, session=None, **kwargs):
+ if not session:
+ raise ValueError(
+ f"must provide session to {self.__class__.__name__}init_batch()"
+ )
+
+ model = self.app.model
+
+ batch.store = (
+ session.query(model.Store)
+ .filter_by(store_id=self.get_store_id(require=True))
+ .one()
+ )
+
+ batch.terminal = (
+ session.query(model.Terminal)
+ .filter_by(terminal_id=self.get_terminal_id(require=True))
+ .one()
+ )
+
+ batch.status_code = batch.STATUS_OK
+
+ # TODO: should also filter this by terminal
+ def get_current_batch(
+ self, user, training_mode=False, create=True, return_created=False
+ ):
+ """
+ Get the "current" POS batch for the given user, creating it as
+ needed.
+
+ :param user: Reference to active user (cashier) for the batch
+ / POS transaction.
+
+ :param training_mode: Flag indicating whether the transaction
+ should be in training mode. The lookup will be restricted
+ according to the value of this flag. If a new batch is
+ created, it will be assigned this flag value.
+
+ :param create: Whether a new batch should be created, if no
+ current batch is found.
+
+ :param return_created: Indicates the return value should
+ include both the batch *and* a flag indicating whether the
+ batch was newly-created.
+ """
+ if not user:
+ raise ValueError("must specify a user")
+
+ created = False
+ model = self.app.model
+ session = self.app.get_session(user)
+ employee = self.app.get_employee(user)
+
+ # TODO: can we assume cashier (employee) is always set /
+ # accurate? if so maybe stop filtering on created_by?
+ cashier_criteria = sa.and_(
+ model.POSBatch.cashier == None, model.POSBatch.created_by == user
+ )
+ if employee:
+ cashier_criteria = sa.or_(
+ model.POSBatch.cashier == employee, cashier_criteria
+ )
+
+ try:
+ batch = (
+ session.query(model.POSBatch)
+ .filter(cashier_criteria)
+ .filter(model.POSBatch.status_code == model.POSBatch.STATUS_OK)
+ .filter(model.POSBatch.executed == None)
+ .filter(model.POSBatch.training_mode == training_mode)
+ .one()
+ )
+
+ except orm.exc.NoResultFound:
+ if not create:
+ if return_created:
+ return None, False
+ return None
+ batch = self.make_batch(
+ session, created_by=user, training_mode=training_mode, cashier=employee
+ )
+ session.add(batch)
+ session.flush()
+ created = True
+
+ if return_created:
+ return batch, created
+
+ return batch
+
+ def get_screen_txn_display(self, batch):
+ """
+ Should return the text to be used for displaying transaction
+ identifier within the header of POS screen.
+ """
+ return batch.id_str
+
+ def get_screen_cust_display(self, batch=None, customer=None):
+ """
+ Should return the text to be used for displaying customer
+ identifier / name etc. within the header of POS screen.
+ """
+ if not customer and batch:
+ customer = batch.customer
+ if customer:
+ return customer.customer_id
+ return None
+
+ # TODO: this should account for shoppers somehow too
+ def set_customer(self, batch, customer, user=None, **kwargs):
+ """
+ Assign the customer account for POS transaction.
+ """
+ enum = self.app.enum
+
+ if customer and batch.customer:
+ row_type = enum.POS_ROW_TYPE_SWAP_CUSTOMER
+ elif customer:
+ row_type = enum.POS_ROW_TYPE_SET_CUSTOMER
+ else:
+ if not batch.customer:
+ return
+ row_type = enum.POS_ROW_TYPE_DEL_CUSTOMER
+
+ batch.customer = customer
+ if customer:
+ # member = self.app.get_member(customer)
+ # batch.customer_is_member = bool(member and member.active)
+ # batch.customer_is_member = False
+ batch.customer_is_member = None
+ employee = self.app.get_employee(customer)
+ batch.customer_is_employee = bool(employee and employee.active)
+ else:
+ batch.customer_is_member = None
+ batch.customer_is_employee = None
+
+ row = self.make_row()
+ row.modified_by = user
+ row.row_type = row_type
+ if customer:
+ row.item_entry = customer.customer_id
+ row.description = (
+ f"{customer.customer_id} - {customer.name}"
+ if customer
+ else "REMOVE CUSTOMER"
+ )
+ self.add_row(batch, row)
+
+ def process_entry(self, batch, entry, quantity=1, user=None, **kwargs):
+ """
+ Process an "entry" value direct from POS. Most typically,
+ this is effectively "ringing up an item" and hence we add a
+ row to the batch and return the row.
+ """
+ session = self.app.get_session(batch)
+ model = self.app.model
+ enum = self.app.enum
+
+ if isinstance(entry, model.Product):
+ product = entry
+ entry = product.uuid
+ else:
+ # TODO: maybe should only search by product key (or GPC?) etc.
+ product = self.app.get_products_handler().locate_product_for_entry(
+ session, entry
+ )
+
+ # TODO: if product not found, should auto-record a badscan
+ # entry? maybe only if config says so, e.g. might be nice to
+ # only record badscan if entry truly came from scanner device,
+ # in which case only the caller would know that
+ if not product:
+ return None
+
+ # if product.not_for_sale:
+ # key = self.app.get_products_handler().render_product_key(product)
+ # raise ValueError(f"product is not for sale: {key}")
+
+ # product located, so add item row
+ row = self.make_row()
+ row.modified_by = user
+ row.item_entry = kwargs.get("item_entry", entry)
+ row.product = product
+ # row.brand_name = product.brand.name if product.brand else None
+ row.description = product.description
+ # row.size = product.size
+ # dept = product.department
+ # if dept:
+ # row.department_number = dept.number
+ # row.department_name = dept.name
+ # row.department = product.department
+ # subdept = product.subdepartment
+ # if subdept:
+ # row.subdepartment_number = subdept.number
+ # row.subdepartment_name = subdept.name
+ # row.foodstamp_eligible = product.food_stampable
+ row.sold_by_weight = product.sold_by_weight
+ row.quantity = quantity
+
+ # regprice = product.regular_price
+ # if regprice:
+ # row.reg_price = regprice.price
+
+ row.reg_price = product.unit_price_reg
+
+ # curprice = product.current_price
+ # if curprice:
+ # row.cur_price = curprice.price
+ # row.cur_price_type = curprice.type
+ # row.cur_price_start = curprice.starts
+ # row.cur_price_end = curprice.ends
+
+ # row.txn_price = row.cur_price or row.reg_price
+ row.txn_price = row.reg_price
+
+ if row.txn_price:
+ row.sales_total = row.txn_price * row.quantity
+ batch.sales_total = (batch.sales_total or 0) + row.sales_total
+ # if row.foodstamp_eligible:
+ # batch.fs_total = (batch.fs_total or 0) + row.sales_total
+
+ # tax = product.tax
+ # if tax:
+ # row.tax_code = tax.code
+
+ if row.txn_price:
+ row.row_type = enum.POS_ROW_TYPE_SELL
+ # if tax:
+ # self.update_tax(batch, row, tax)
+ else:
+ row.row_type = enum.POS_ROW_TYPE_BADPRICE
+
+ self.add_row(batch, row)
+ session.flush()
+ return row
+
+ # def update_tax(self, batch, row, tax=None, tax_code=None, **kwargs):
+ # """
+ # Update the tax totals for the batch, basd on given row.
+ # """
+ # if not tax and not tax_code:
+ # raise ValueError("must specify either tax or tax_code")
+
+ # session = self.app.get_session(batch)
+ # if not tax:
+ # tax = self.get_tax(session, tax_code)
+
+ # btax = batch.taxes.get(tax.code)
+ # if not btax:
+ # btax = self.model.POSBatchTax()
+ # btax.tax = tax
+ # btax.tax_code = tax.code
+ # btax.tax_rate = tax.rate
+ # session.add(btax)
+ # btax.batch = batch
+ # session.flush()
+
+ # # calculate relevant sales
+ # rows = [r for r in batch.active_rows() if r.tax_code == tax.code and not r.void]
+ # sales = sum([r.sales_total for r in rows])
+ # # nb. must add row separately if not yet in batch
+ # if not row.batch and not row.batch_uuid:
+ # sales += row.sales_total
+
+ # # total for this tax
+ # before = btax.tax_total or 0
+ # btax.tax_total = (sales * (tax.rate / 100)).quantize(decimal.Decimal("0.02"))
+ # batch.tax_total = (batch.tax_total or 0) - before + btax.tax_total
+
+ # def record_badscan(self, batch, entry, quantity=1, user=None, **kwargs):
+ # """
+ # Add a row to the batch which represents a "bad scan" at POS.
+ # """
+ # row = self.make_row()
+ # row.user = user
+ # row.row_type = self.enum.POS_ROW_TYPE_BADSCAN
+ # row.item_entry = entry
+ # row.description = "BADSCAN"
+ # row.quantity = quantity
+ # self.add_row(batch, row)
+ # return row
+
+ def add_open_ring(self, batch, department, price, quantity=1, user=None, **kwargs):
+ """
+ Adds an "open ring" row to the batch.
+ """
+ model = self.app.model
+ enum = self.app.enum
+ session = self.app.get_session(batch)
+
+ if not isinstance(department, model.Department):
+ department = session.get(model.Department, department)
+ if not department:
+ raise ValueError("must specify valid department")
+
+ # add row for open ring
+ row = self.make_row()
+ row.row_type = enum.POS_ROW_TYPE_OPEN_RING
+ row.modified_by = user
+ row.item_entry = department.department_id
+ row.description = f"OPEN RING: {department.name}"
+ # row.department_number = department.number
+ # row.department_name = department.name
+ # row.department = department
+ # row.foodstamp_eligible = department.food_stampable
+ row.quantity = quantity
+
+ row.txn_price = price
+ if row.txn_price:
+ row.sales_total = row.txn_price * row.quantity
+ batch.sales_total = (batch.sales_total or 0) + row.sales_total
+ # if row.foodstamp_eligible:
+ # batch.fs_total = (batch.fs_total or 0) + row.sales_total
+
+ # tax = department.tax
+ # if tax:
+ # row.tax_code = tax.code
+ # self.update_tax(batch, row, tax)
+
+ self.add_row(batch, row)
+ session.flush()
+ return row
+
+ # def get_tax(self, session, code, **kwargs):
+ # """
+ # Return the tax record corresponding to the given code.
+
+ # :param session: Current DB session.
+
+ # :param code: Tax code to fetch.
+ # """
+ # model = self.model
+ # return session.query(model.Tax).filter(model.Tax.code == code).one()
+
+ # def get_tender(self, session, key, **kwargs):
+ # """
+ # Return the tender record corresponding to the given key.
+
+ # :param session: Current DB session.
+
+ # :param key: Either a tender UUID, or "true" tender code (i.e.
+ # :attr:`rattail.db.model.sales.Tender.code` value) or a
+ # "pseudo-code" for common tenders (e.g. ``'cash'``).
+ # """
+ # model = self.model
+
+ # # Tender.uuid match?
+ # tender = session.get(model.Tender, key)
+ # if tender:
+ # return tender
+
+ # # Tender.code match?
+ # try:
+ # return session.query(model.Tender).filter(model.Tender.code == key).one()
+ # except orm.exc.NoResultFound:
+ # pass
+
+ # # try settings, if value then recurse
+ # # TODO: not sure why get_vendor() only checks settings?
+ # # for now am assuming we should also check config file
+ # # key = self.app.get_setting(session, f'rattail.tender.{key}')
+ # key = self.config.get("rattail", f"tender.{key}")
+ # if key:
+ # return self.get_tender(session, key, **kwargs)
+
+ def refresh_row(self, row):
+ # TODO (?)
+ row.status_code = row.STATUS_OK
+ row.status_text = None
+
+ # # TODO: this subclass used to override the base method here, but for
+ # # the moment we implement the full method below, sans customization.
+ # def clone(self, oldbatch, created_by, **kwargs):
+ # newbatch = super().clone(oldbatch, created_by, **kwargs)
+ # session = self.app.get_session(oldbatch)
+ # model = self.app.model
+
+ # # tax_mapper = sa.inspect(model.POSBatchTax)
+ # # for oldtax in oldbatch.taxes.values():
+ # # newtax = model.POSBatchTax()
+ # # for key in tax_mapper.columns.keys():
+ # # if key not in ("uuid", "batch_uuid"):
+ # # setattr(newtax, key, getattr(oldtax, key))
+ # # session.add(newtax)
+ # # newtax.batch = newbatch
+
+ # return newbatch
+
+ # TODO: this used to be in the BatchHandler base class, and i
+ # guess it probably still should be, once it's vetted. also
+ # see the note above for custom clone() method.
+ def clone(self, oldbatch, created_by, progress=None, **kwargs):
+ """
+ Clone the given batch as a new batch, and return the new batch.
+ """
+ session = self.app.get_session(oldbatch)
+
+ # self.setup_clone(oldbatch, progress=progress)
+ batch_class = self.model_class
+ batch_mapper = sa.inspect(batch_class)
+
+ newbatch = batch_class()
+ newbatch.id = self.consume_batch_id(session)
+ newbatch.created_by = created_by
+ newbatch.row_count = 0
+ for name in batch_mapper.columns.keys():
+ if name not in (
+ "uuid",
+ "id",
+ "created",
+ "created_by_uuid",
+ "row_count",
+ "executed",
+ "executed_by_uuid",
+ ):
+ setattr(newbatch, name, getattr(oldbatch, name))
+
+ session.add(newbatch)
+ session.flush()
+
+ row_class = newbatch.__row_class__
+ row_mapper = sa.inspect(row_class)
+
+ def clone_row(oldrow, i):
+ newrow = self.clone_row(oldrow)
+ self.add_row(newbatch, newrow)
+
+ self.app.progress_loop(
+ clone_row,
+ self.get_clonable_rows(oldbatch),
+ progress,
+ message="Cloning data rows for new batch",
+ )
+
+ self.refresh_batch_status(newbatch)
+ # self.teardown_clone(newbatch, progress=progress)
+ return newbatch
+
+ # # TODO: this used to be defined by the base method as shown here
+ # def get_clonable_rows(self, batch, **kwargs):
+ # return batch.data_rows
+
+ # TODO: for now we are providing the only implementation; see above
+ def get_clonable_rows(self, batch, **kwargs):
+ enum = self.app.enum
+ # TODO: row types..ugh
+ return [row for row in batch.rows if row.row_type != enum.POS_ROW_TYPE_TENDER]
+
+ # # TODO: this used to be defined by the base method as shown here (i think)
+ def clone_row(self, oldrow):
+ row_class = self.model_class.__row_class__
+ row_mapper = sa.inspect(row_class)
+ newrow = row_class()
+ for name in row_mapper.columns.keys():
+ if name not in ("uuid", "batch_uuid", "sequence"):
+ setattr(newrow, name, getattr(oldrow, name))
+ return newrow
+
+ def override_price(self, row, user, txn_price, **kwargs):
+ """
+ Override the transaction price for the given batch row.
+ """
+ enum = self.app.enum
+ batch = row.batch
+
+ # update price for given row
+ orig_row = row
+ orig_txn_price = orig_row.txn_price
+ orig_sales_total = orig_row.sales_total
+ orig_row.txn_price = txn_price
+ orig_row.txn_price_adjusted = True
+ orig_row.sales_total = orig_row.quantity * orig_row.txn_price
+
+ # adjust totals
+ batch.sales_total = (
+ (batch.sales_total or 0) - orig_sales_total + orig_row.sales_total
+ )
+ # if orig_row.foodstamp_eligible:
+ # batch.fs_total = (
+ # (batch.fs_total or 0) - orig_sales_total + orig_row.sales_total
+ # )
+ # if orig_row.tax_code:
+ # self.update_tax(batch, orig_row, tax_code=orig_row.tax_code)
+
+ # add another row indicating who/when
+ row = self.make_row()
+ row.modified_by = user
+ row.row_type = enum.POS_ROW_TYPE_ADJUST_PRICE
+ row.item_entry = orig_row.item_entry
+ row.txn_price = txn_price
+ row.description = (
+ f"ROW {orig_row.sequence} PRICE ADJUST "
+ f"FROM {self.app.render_currency(orig_txn_price)}"
+ )
+ self.add_row(batch, row)
+ return row
+
+ def void_row(self, row, user, **kwargs):
+ """
+ Apply "void" status to the given batch row.
+ """
+ enum = self.app.enum
+ batch = row.batch
+
+ # mark given row as void
+ orig_row = row
+ orig_row.void = True
+
+ # adjust batch totals
+ if orig_row.sales_total:
+ batch.sales_total = (batch.sales_total or 0) - orig_row.sales_total
+ # if orig_row.foodstamp_eligible:
+ # batch.fs_total = (batch.fs_total or 0) - orig_row.sales_total
+ # if orig_row.tax_code:
+ # self.update_tax(batch, orig_row, tax_code=orig_row.tax_code)
+
+ # add another row indicating who/when
+ row = self.make_row()
+ row.modified_by = user
+ row.row_type = enum.POS_ROW_TYPE_VOID_LINE
+ row.item_entry = orig_row.item_entry
+ row.description = f"VOID ROW {orig_row.sequence}"
+ self.add_row(batch, row)
+ return row
+
+ def suspend_transaction(self, batch, user, **kwargs):
+ """
+ Suspend transaction for the given POS batch.
+ """
+ enum = self.app.enum
+
+ # add another row indicating who/when
+ row = self.make_row()
+ row.modified_by = user
+ row.row_type = enum.POS_ROW_TYPE_SUSPEND
+ row.description = "SUSPEND TXN"
+ self.add_row(batch, row)
+
+ # TODO: should do something different if we have a server
+ # engine, i.e. central location for suspended txns
+
+ # mark batch as suspended (but not executed)
+ batch.status_code = batch.STATUS_SUSPENDED
+
+ def resume_transaction(self, batch, user, **kwargs):
+ """
+ Resume transaction for the given POS batch. By default this
+ always creates a *new* batch.
+ """
+ enum = self.app.enum
+
+ # TODO: should do something different if we have a server
+ # engine, i.e. central location for suspended txns
+
+ newbatch = self.clone(batch, user)
+ newbatch.cashier = self.app.get_employee(user)
+ newbatch.status_code = newbatch.STATUS_OK
+
+ # add another row indicating who/when
+ row = self.make_row()
+ row.modified_by = user
+ row.row_type = enum.POS_ROW_TYPE_RESUME
+ row.description = "RESUME TXN"
+ self.add_row(newbatch, row)
+
+ # mark original batch as executed
+ batch.executed = self.app.make_utc()
+ batch.executed_by = user
+
+ return newbatch
+
+ def void_batch(self, batch, user, **kwargs):
+ """
+ Void the given POS batch.
+ """
+ enum = self.app.enum
+
+ # add another row indicating who/when
+ row = self.make_row()
+ row.modified_by = user
+ row.row_type = enum.POS_ROW_TYPE_VOID_TXN
+ row.description = "VOID TXN"
+ self.add_row(batch, row)
+
+ # void/execute batch
+ batch.void = True
+ batch.executed = self.app.make_utc()
+ batch.executed_by = user
+
+ # def apply_tender(self, batch, user, tender, amount, **kwargs):
+ # """
+ # Apply the given tender amount to the batch.
+
+ # :param tender: Reference to a
+ # :class:`~rattail.db.model.sales.Tender` or similar object, or dict
+ # with similar keys, or can be just a tender code.
+
+ # :param amount: Amount to apply. Note, this usually should be
+ # a *negative* number.
+
+ # :returns: List of rows which were added to the batch.
+ # """
+ # session = self.app.get_session(batch)
+ # model = self.model
+
+ # tender_info = tender
+ # tender = None
+ # item_entry = None
+ # description = None
+
+ # if isinstance(tender_info, model.Tender):
+ # tender = tender_info
+ # else:
+ # if isinstance(tender_info, str):
+ # tender_code = tender_info
+ # else:
+ # tender_code = tender_info["code"]
+ # item_entry = tender_code
+ # description = f"TENDER '{tender_code}'"
+ # tender = self.get_tender(session, tender_code)
+
+ # if tender:
+ # item_entry = tender.code
+ # description = tender.name
+
+ # if tender.disabled:
+ # # TODO: behavior here should be configurable, probably
+ # # needs a dedicated email etc. ..or maybe just ignore
+ # log.error(
+ # "disabled tender '%s' being applied to POS batch: %s", tender, batch
+ # )
+
+ # rows = []
+
+ # # add row for tender
+ # row = self.make_row()
+ # row.user = user
+ # row.row_type = self.enum.POS_ROW_TYPE_TENDER
+ # row.item_entry = item_entry
+ # row.description = description
+ # row.tender_total = amount
+ # row.tender = tender
+ # batch.tender_total = (batch.tender_total or 0) + row.tender_total
+ # if tender and tender.is_foodstamp:
+ # batch.fs_tender_total = (batch.fs_tender_total or 0) + row.tender_total
+ # self.add_row(batch, row)
+ # rows.append(row)
+
+ # # nothing more to do for now, if balance remains
+ # balance = batch.get_balance()
+ # if balance > 0:
+ # return rows
+
+ # # next we'll give change back
+ # # nb. if balance is 0, then change due is 0, but we always
+ # # include the change due line item even so..
+
+ # # ..but some tenders do not allow cash back
+ # if balance < 0:
+ # if hasattr(tender, "is_cash") and not tender.is_cash:
+ # if not tender.allow_cash_back:
+ # raise ValueError(
+ # f"tender '{tender.name}' does not allow "
+ # f" cash back: ${-balance:0.2f}"
+ # )
+
+ # # TODO: maybe should always give change as 'cash'
+ # if tender and tender.is_cash:
+ # cash = tender
+ # else:
+ # cash = self.get_tender(session, "cash")
+
+ # row = self.make_row()
+ # row.user = user
+ # row.row_type = self.enum.POS_ROW_TYPE_CHANGE_BACK
+ # row.item_entry = item_entry
+ # row.description = "CHANGE DUE"
+ # row.tender_total = -balance
+ # row.tender = cash
+ # batch.tender_total = (batch.tender_total or 0) + row.tender_total
+ # self.add_row(batch, row)
+ # rows.append(row)
+
+ # # all paid up, so finalize
+ # session.flush()
+ # assert batch.get_balance() == 0
+ # self.do_execute(batch, user, **kwargs)
+ # return rows
+
+ # def execute(self, batch, progress=None, **kwargs):
+ # # TODO
+ # return True
diff --git a/src/wuttapos/clientele.py b/src/wuttapos/clientele.py
new file mode 100644
index 0000000..83ff76a
--- /dev/null
+++ b/src/wuttapos/clientele.py
@@ -0,0 +1,284 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+Clientele Handler
+"""
+
+# from collections import OrderedDict
+import logging
+import uuid as _uuid
+
+# import warnings
+
+from sqlalchemy import orm
+
+from wuttjamaican.app import GenericHandler
+
+
+log = logging.getLogger(__name__)
+
+
+class ClienteleHandler(GenericHandler):
+ """
+ Base class and default implementation for clientele handlers.
+ """
+
+ # def get_customer(self, obj):
+ # """
+ # Return the Customer associated with the given object, if any.
+ # """
+ # model = self.model
+
+ # if isinstance(obj, model.Customer):
+ # return obj
+
+ # else:
+ # person = self.app.get_person(obj)
+ # if person:
+ # # TODO: all 3 options below are indeterminate, since it's
+ # # *possible* for a person to hold multiple accounts
+ # # etc. but not sure how to fix in a generic way? maybe
+ # # just everyone must override as needed
+ # if person.customer_accounts:
+ # return person.customer_accounts[0]
+ # for shopper in person.customer_shoppers:
+ # if shopper.shopper_number == 1:
+ # return shopper.customer
+ # # legacy fallback
+ # if person.customers:
+ # return person.customers[0]
+
+ # def make_customer(self, person, **kwargs):
+ # """
+ # Create and return a new customer record.
+ # """
+ # session = self.app.get_session(person)
+ # customer = self.model.Customer()
+ # customer.name = person.display_name
+ # customer.account_holder = person
+ # session.add(customer)
+ # session.flush()
+ # session.refresh(person)
+ # return customer
+
+ def locate_customer_for_entry(self, session, entry, **kwargs):
+ """
+ This method aims to provide sane default logic for locating a
+ :class:`~rattail.db.model.customers.Customer` record for the
+ given "entry" value.
+
+ The default logic here will try to honor the "configured"
+ customer field, and prefer that when attempting the lookup.
+
+ :param session: Reference to current DB session.
+
+ :param entry: Value to use for lookup. This is most often a
+ simple string, but the method can handle a few others. For
+ instance it is common to read values from a spreadsheet,
+ and sometimes those come through as integers etc.
+
+ :param lookup_fields: Optional list of fields to use for
+ lookup. The default value is ``['uuid', '_customer_key_']``
+ which means to lookup by UUID as well as "customer key"
+ field, which is configurable. You can include any of the
+ following in ``lookup_fields``:
+
+ * ``uuid``
+ * ``_customer_key_`` - :meth:`locate_customer_for_key`
+
+ :returns: First :class:`~rattail.db.model.customers.Customer`
+ instance found if there was a match; otherwise ``None``.
+ """
+ model = self.app.model
+ if not entry:
+ return None
+
+ # figure out which fields we should match on
+ # TODO: let config declare default lookup_fields
+ lookup_fields = kwargs.get(
+ "lookup_fields",
+ [
+ "uuid",
+ "customer_id",
+ ],
+ )
+
+ # try to locate customer by uuid before other, more specific key
+ if "uuid" in lookup_fields:
+ if isinstance(entry, (_uuid.UUID, str)):
+ customer = session.get(model.Customer, entry)
+ if customer:
+ return customer
+
+ lookups = {
+ "uuid": None,
+ "customer_id": self.locate_customer_for_id,
+ }
+
+ for field in lookup_fields:
+ if field in lookups:
+ lookup = lookups[field]
+ if lookup:
+ customer = lookup(session, entry, **kwargs)
+ if customer:
+ return customer
+ else:
+ log.warning("unknown lookup field: %s", field)
+
+ def locate_customer_for_id(self, session, entry, **kwargs):
+ """
+ Locate the customer which matches the given ID.
+
+ This will do a lookup on the
+ :attr:`rattail.db.model.customers.Customer.id` field only.
+
+ Note that instead of calling this method directly, you might
+ consider calling :meth:`locate_customer_for_key()` instead.
+
+ :param session: Current session for Rattail DB.
+
+ :param entry: Customer ID value as string.
+
+ :returns: First :class:`~rattail.db.model.customers.Customer`
+ instance found if there was a match; otherwise ``None``.
+ """
+ if not entry:
+ return None
+
+ # assume entry is string
+ entry = str(entry)
+
+ model = self.app.model
+ try:
+ return (
+ session.query(model.Customer)
+ .filter(model.Customer.customer_id == entry)
+ .one()
+ )
+ except orm.exc.NoResultFound:
+ return None
+
+ def search_customers(self, session, entry, **kwargs):
+ """
+ Perform a customer search across multiple fields, and return
+ results as JSON data rows.
+ """
+ model = self.app.model
+ final_results = []
+
+ # first we'll attempt "lookup" logic..
+
+ lookup_fields = kwargs.get(
+ "lookup_fields",
+ ["customer_id"],
+ )
+
+ if lookup_fields:
+ customer = self.locate_customer_for_entry(
+ session, entry, lookup_fields=lookup_fields
+ )
+ if customer:
+ final_results.append(customer)
+
+ # then we'll attempt "search" logic..
+
+ search_fields = kwargs.get(
+ "search_fields",
+ [
+ "name",
+ "email_address",
+ "phone_number",
+ ],
+ )
+
+ searches = {
+ "name": self.search_customers_for_name,
+ "email_address": self.search_customers_for_email_address,
+ "phone_number": self.search_customers_for_phone_number,
+ }
+
+ for field in search_fields:
+ if field in searches:
+ search = searches[field]
+ if search:
+ customers = search(session, entry, **kwargs)
+ final_results.extend(customers)
+ else:
+ log.warning("unknown search field: %s", field)
+
+ return [self.normalize_customer(c) for c in final_results]
+
+ def search_customers_for_name(self, session, entry, **kwargs):
+ model = self.app.model
+ entry = entry.lower()
+
+ return (
+ session.query(model.Customer)
+ .filter(model.Customer.name.ilike(f"%{entry}%"))
+ .all()
+ )
+
+ def search_customers_for_email_address(self, session, entry, **kwargs):
+ model = self.app.model
+ entry = entry.lower()
+
+ return (
+ session.query(model.Customer)
+ .filter(model.Customer.email_address.ilike(f"%{entry}%"))
+ .all()
+ )
+
+ def search_customers_for_phone_number(self, session, entry, **kwargs):
+ model = self.app.model
+ entry = entry.lower()
+
+ return (
+ session.query(model.Customer)
+ .filter(model.Customer.phone_number.ilike(f"%{entry}%"))
+ .all()
+ )
+
+ def normalize_customer(self, customer):
+ """
+ Normalize the given customer to a JSON-serializable dict.
+ """
+ return {
+ "uuid": customer.uuid,
+ "customer_id": customer.customer_id,
+ "name": customer.name,
+ "phone_number": customer.phone_number,
+ "email_address": customer.email_address,
+ "_str": str(customer),
+ }
+
+ def get_customer_info_markdown(self, customer, **kwargs):
+ """
+ Returns a Markdown string containing pertinent info about a
+ given customer account.
+ """
+ return (
+ f"Customer ID: {customer.customer_id}\n\n"
+ f"Name: {customer.name}\n\n"
+ f"Phone: {customer.phone_number or ''}\n\n"
+ f"Email: {customer.email_address or ''}\n\n"
+ )
diff --git a/src/wuttapos/config.py b/src/wuttapos/config.py
index 4157f58..f4902f7 100644
--- a/src/wuttapos/config.py
+++ b/src/wuttapos/config.py
@@ -42,6 +42,7 @@ class WuttaPosConfigExtension(WuttaConfigExtension):
# app model
config.setdefault(f"{config.appname}.model_spec", "wuttapos.db.model")
+ config.setdefault(f"{config.appname}.enum_spec", "wuttapos.enum")
# # auth handler
# config.setdefault(
diff --git a/src/wuttapos/db/alembic/versions/3f548013be91_add_stores.py b/src/wuttapos/db/alembic/versions/3f548013be91_add_stores.py
new file mode 100644
index 0000000..366adcf
--- /dev/null
+++ b/src/wuttapos/db/alembic/versions/3f548013be91_add_stores.py
@@ -0,0 +1,80 @@
+"""add Stores
+
+Revision ID: 3f548013be91
+Revises: 7880450562d5
+Create Date: 2026-01-02 10:18:20.199691
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "3f548013be91"
+down_revision: Union[str, None] = "32e965f42f0f"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # store
+ op.create_table(
+ "store",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("store_id", sa.String(length=20), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_store")),
+ )
+ op.create_table(
+ "store_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("store_id", sa.String(length=20), autoincrement=False, nullable=True),
+ sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
+ sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_store_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_store_version_end_transaction_id"),
+ "store_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_store_version_operation_type"),
+ "store_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_store_version_transaction_id"),
+ "store_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # store
+ op.drop_index(op.f("ix_store_version_transaction_id"), table_name="store_version")
+ op.drop_index(op.f("ix_store_version_operation_type"), table_name="store_version")
+ op.drop_index(
+ op.f("ix_store_version_end_transaction_id"), table_name="store_version"
+ )
+ op.drop_table("store_version")
+ op.drop_table("store")
diff --git a/src/wuttapos/db/alembic/versions/653b7d27c709_add_terminals.py b/src/wuttapos/db/alembic/versions/653b7d27c709_add_terminals.py
new file mode 100644
index 0000000..4243449
--- /dev/null
+++ b/src/wuttapos/db/alembic/versions/653b7d27c709_add_terminals.py
@@ -0,0 +1,84 @@
+"""add Terminals
+
+Revision ID: 653b7d27c709
+Revises: 3f548013be91
+Create Date: 2026-01-02 11:08:43.118117
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "653b7d27c709"
+down_revision: Union[str, None] = "3f548013be91"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # terminal
+ op.create_table(
+ "terminal",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("terminal_id", sa.String(length=20), nullable=False),
+ sa.Column("name", sa.String(length=50), nullable=False),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_terminal")),
+ )
+ op.create_table(
+ "terminal_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "terminal_id", sa.String(length=20), autoincrement=False, nullable=True
+ ),
+ sa.Column("name", sa.String(length=50), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_terminal_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_terminal_version_end_transaction_id"),
+ "terminal_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_terminal_version_operation_type"),
+ "terminal_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_terminal_version_transaction_id"),
+ "terminal_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # terminal
+ op.drop_index(
+ op.f("ix_terminal_version_transaction_id"), table_name="terminal_version"
+ )
+ op.drop_index(
+ op.f("ix_terminal_version_operation_type"), table_name="terminal_version"
+ )
+ op.drop_index(
+ op.f("ix_terminal_version_end_transaction_id"), table_name="terminal_version"
+ )
+ op.drop_table("terminal_version")
+ op.drop_table("terminal")
diff --git a/src/wuttapos/db/alembic/versions/7067ef686eb0_add_taxes.py b/src/wuttapos/db/alembic/versions/7067ef686eb0_add_taxes.py
new file mode 100644
index 0000000..6b200e5
--- /dev/null
+++ b/src/wuttapos/db/alembic/versions/7067ef686eb0_add_taxes.py
@@ -0,0 +1,80 @@
+"""add Taxes
+
+Revision ID: 7067ef686eb0
+Revises: b026e4f5c5bc
+Create Date: 2026-01-02 12:15:45.279817
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "7067ef686eb0"
+down_revision: Union[str, None] = "b026e4f5c5bc"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # tax
+ op.create_table(
+ "tax",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("tax_id", sa.String(length=20), nullable=False),
+ sa.Column("name", sa.String(length=50), nullable=False),
+ sa.Column("rate", sa.Numeric(precision=7, scale=5), nullable=False),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_tax")),
+ )
+ op.create_table(
+ "tax_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column("tax_id", sa.String(length=20), autoincrement=False, nullable=True),
+ sa.Column("name", sa.String(length=50), autoincrement=False, nullable=True),
+ sa.Column(
+ "rate", sa.Numeric(precision=7, scale=5), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint("uuid", "transaction_id", name=op.f("pk_tax_version")),
+ )
+ op.create_index(
+ op.f("ix_tax_version_end_transaction_id"),
+ "tax_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_tax_version_operation_type"),
+ "tax_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_tax_version_transaction_id"),
+ "tax_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # tax
+ op.drop_table("batch_pos_row")
+ op.drop_table("batch_pos")
+ op.drop_index(op.f("ix_tax_version_transaction_id"), table_name="tax_version")
+ op.drop_index(op.f("ix_tax_version_operation_type"), table_name="tax_version")
+ op.drop_index(op.f("ix_tax_version_end_transaction_id"), table_name="tax_version")
+ op.drop_table("tax_version")
+ op.drop_table("tax")
diff --git a/src/wuttapos/db/alembic/versions/8ce8b14af66d_add_employees.py b/src/wuttapos/db/alembic/versions/8ce8b14af66d_add_employees.py
new file mode 100644
index 0000000..bab7d1e
--- /dev/null
+++ b/src/wuttapos/db/alembic/versions/8ce8b14af66d_add_employees.py
@@ -0,0 +1,98 @@
+"""add Employees
+
+Revision ID: 8ce8b14af66d
+Revises: 653b7d27c709
+Create Date: 2026-01-02 11:18:07.359270
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "8ce8b14af66d"
+down_revision: Union[str, None] = "653b7d27c709"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # employee
+ op.create_table(
+ "employee",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("person_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("name", sa.String(length=100), nullable=False),
+ sa.Column("public_name", sa.String(length=100), nullable=True),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["person_uuid"],
+ ["person.uuid"],
+ name=op.f("fk_employee_person_uuid_person"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_employee")),
+ )
+ op.create_table(
+ "employee_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "person_uuid",
+ wuttjamaican.db.util.UUID(),
+ autoincrement=False,
+ nullable=True,
+ ),
+ sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True),
+ sa.Column(
+ "public_name", sa.String(length=100), autoincrement=False, nullable=True
+ ),
+ sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_employee_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_employee_version_end_transaction_id"),
+ "employee_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_employee_version_operation_type"),
+ "employee_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_employee_version_transaction_id"),
+ "employee_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # employee
+ op.drop_index(
+ op.f("ix_employee_version_transaction_id"), table_name="employee_version"
+ )
+ op.drop_index(
+ op.f("ix_employee_version_operation_type"), table_name="employee_version"
+ )
+ op.drop_index(
+ op.f("ix_employee_version_end_transaction_id"), table_name="employee_version"
+ )
+ op.drop_table("employee_version")
+ op.drop_table("employee")
diff --git a/src/wuttapos/db/alembic/versions/b026e4f5c5bc_add_tenders.py b/src/wuttapos/db/alembic/versions/b026e4f5c5bc_add_tenders.py
new file mode 100644
index 0000000..a8161f8
--- /dev/null
+++ b/src/wuttapos/db/alembic/versions/b026e4f5c5bc_add_tenders.py
@@ -0,0 +1,92 @@
+"""add Tenders
+
+Revision ID: b026e4f5c5bc
+Revises: 8ce8b14af66d
+Create Date: 2026-01-02 11:34:27.523125
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "b026e4f5c5bc"
+down_revision: Union[str, None] = "8ce8b14af66d"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # tender
+ op.create_table(
+ "tender",
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("tender_id", sa.String(length=20), nullable=False),
+ sa.Column("name", sa.String(length=50), nullable=False),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("is_cash", sa.Boolean(), nullable=False),
+ sa.Column("is_foodstamp", sa.Boolean(), nullable=False),
+ sa.Column("allow_cashback", sa.Boolean(), nullable=False),
+ sa.Column("kick_drawer", sa.Boolean(), nullable=False),
+ sa.Column("active", sa.Boolean(), nullable=False),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_tender")),
+ )
+ op.create_table(
+ "tender_version",
+ sa.Column(
+ "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
+ ),
+ sa.Column(
+ "tender_id", sa.String(length=20), autoincrement=False, nullable=True
+ ),
+ sa.Column("name", sa.String(length=50), autoincrement=False, nullable=True),
+ sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
+ sa.Column("is_cash", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("is_foodstamp", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("allow_cashback", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("kick_drawer", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column("active", sa.Boolean(), autoincrement=False, nullable=True),
+ sa.Column(
+ "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
+ ),
+ sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
+ sa.Column("operation_type", sa.SmallInteger(), nullable=False),
+ sa.PrimaryKeyConstraint(
+ "uuid", "transaction_id", name=op.f("pk_tender_version")
+ ),
+ )
+ op.create_index(
+ op.f("ix_tender_version_end_transaction_id"),
+ "tender_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_tender_version_operation_type"),
+ "tender_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_tender_version_transaction_id"),
+ "tender_version",
+ ["transaction_id"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+
+ # tender
+ op.drop_index(op.f("ix_tender_version_transaction_id"), table_name="tender_version")
+ op.drop_index(op.f("ix_tender_version_operation_type"), table_name="tender_version")
+ op.drop_index(
+ op.f("ix_tender_version_end_transaction_id"), table_name="tender_version"
+ )
+ op.drop_table("tender_version")
+ op.drop_table("tender")
diff --git a/src/wuttapos/db/alembic/versions/c2b1f0983c97_add_pos_batches.py b/src/wuttapos/db/alembic/versions/c2b1f0983c97_add_pos_batches.py
new file mode 100644
index 0000000..d55db7a
--- /dev/null
+++ b/src/wuttapos/db/alembic/versions/c2b1f0983c97_add_pos_batches.py
@@ -0,0 +1,135 @@
+"""add POS Batches
+
+Revision ID: c2b1f0983c97
+Revises: 7067ef686eb0
+Create Date: 2026-01-02 13:11:37.703157
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import wuttjamaican.db.util
+
+
+# revision identifiers, used by Alembic.
+revision: str = "c2b1f0983c97"
+down_revision: Union[str, None] = "7067ef686eb0"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # batch_pos
+ op.create_table(
+ "batch_pos",
+ sa.Column("store_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("terminal_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("cashier_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("customer_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("customer_is_member", sa.Boolean(), nullable=True),
+ sa.Column("customer_is_employee", sa.Boolean(), nullable=True),
+ sa.Column("sales_total", sa.Numeric(precision=9, scale=2), nullable=True),
+ sa.Column("fs_total", sa.Numeric(precision=9, scale=2), nullable=True),
+ sa.Column("tax_total", sa.Numeric(precision=9, scale=2), nullable=True),
+ sa.Column("fs_tender_total", sa.Numeric(precision=9, scale=2), nullable=True),
+ sa.Column("tender_total", sa.Numeric(precision=9, scale=2), nullable=True),
+ sa.Column("void", sa.Boolean(), nullable=False),
+ sa.Column("training_mode", sa.Boolean(), nullable=False),
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("description", sa.String(length=255), nullable=True),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column("row_count", sa.Integer(), nullable=True),
+ sa.Column("status_code", sa.Integer(), nullable=True),
+ sa.Column("status_text", sa.String(length=255), nullable=True),
+ sa.Column("created", sa.DateTime(), nullable=False),
+ sa.Column("created_by_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("executed", sa.DateTime(), nullable=True),
+ sa.Column("executed_by_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["cashier_uuid"],
+ ["employee.uuid"],
+ name=op.f("fk_batch_pos_cashier_uuid_employee"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["created_by_uuid"],
+ ["user.uuid"],
+ name=op.f("fk_batch_pos_created_by_uuid_user"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["customer_uuid"],
+ ["customer.uuid"],
+ name=op.f("fk_batch_pos_customer_uuid_customer"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["executed_by_uuid"],
+ ["user.uuid"],
+ name=op.f("fk_batch_pos_executed_by_uuid_user"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["store_uuid"], ["store.uuid"], name=op.f("fk_batch_pos_store_uuid_store")
+ ),
+ sa.ForeignKeyConstraint(
+ ["terminal_uuid"],
+ ["terminal.uuid"],
+ name=op.f("fk_batch_pos_terminal_uuid_terminal"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_batch_pos")),
+ )
+
+ # batch_pos_row
+ op.create_table(
+ "batch_pos_row",
+ sa.Column("modified_by_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("row_type", sa.String(length=20), nullable=False),
+ sa.Column("item_entry", sa.String(length=20), nullable=True),
+ sa.Column("product_uuid", wuttjamaican.db.util.UUID(), nullable=True),
+ sa.Column("description", sa.String(length=100), nullable=True),
+ sa.Column("foodstamp_eligible", sa.Boolean(), nullable=True),
+ sa.Column("sold_by_weight", sa.Boolean(), nullable=True),
+ sa.Column("quantity", sa.Numeric(precision=8, scale=2), nullable=True),
+ sa.Column("cost", sa.Numeric(precision=8, scale=3), nullable=True),
+ sa.Column("reg_price", sa.Numeric(precision=8, scale=3), nullable=True),
+ sa.Column("cur_price", sa.Numeric(precision=8, scale=3), nullable=True),
+ sa.Column("cur_price_type", sa.Integer(), nullable=True),
+ sa.Column("cur_price_start", sa.DateTime(), nullable=True),
+ sa.Column("cur_price_end", sa.DateTime(), nullable=True),
+ sa.Column("txn_price", sa.Numeric(precision=8, scale=3), nullable=True),
+ sa.Column("txn_price_adjusted", sa.Boolean(), nullable=True),
+ sa.Column("sales_total", sa.Numeric(precision=9, scale=2), nullable=True),
+ sa.Column("tax_code", sa.String(length=30), nullable=True),
+ sa.Column("tender_total", sa.Numeric(precision=9, scale=2), nullable=True),
+ sa.Column("void", sa.Boolean(), nullable=False),
+ sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("batch_uuid", wuttjamaican.db.util.UUID(), nullable=False),
+ sa.Column("sequence", sa.Integer(), nullable=False),
+ sa.Column("status_code", sa.Integer(), nullable=True),
+ sa.Column("status_text", sa.String(length=255), nullable=True),
+ sa.Column("modified", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["batch_uuid"],
+ ["batch_pos.uuid"],
+ name=op.f("fk_batch_pos_row_batch_uuid_batch_pos"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["modified_by_uuid"],
+ ["user.uuid"],
+ name=op.f("fk_batch_pos_row_modified_by_uuid_user"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["product_uuid"],
+ ["product.uuid"],
+ name=op.f("fk_batch_pos_row_product_uuid_product"),
+ ),
+ sa.PrimaryKeyConstraint("uuid", name=op.f("pk_batch_pos_row")),
+ )
+
+
+def downgrade() -> None:
+
+ # batch_pos*
+ op.drop_table("batch_pos_row")
+ op.drop_table("batch_pos")
diff --git a/src/wuttapos/db/model/__init__.py b/src/wuttapos/db/model/__init__.py
index 98e0ab8..12b89b0 100644
--- a/src/wuttapos/db/model/__init__.py
+++ b/src/wuttapos/db/model/__init__.py
@@ -26,6 +26,11 @@ WuttaPOS - data model
from wuttjamaican.db.model import *
+from .stores import Store
+from .terminals import Terminal
+from .tenders import Tender
+from .taxes import Tax
+from .employees import Employee
from .customers import Customer
from .departments import Department
from .products import (
@@ -34,3 +39,5 @@ from .products import (
InventoryAdjustmentType,
InventoryAdjustment,
)
+
+from .batch.pos import POSBatch, POSBatchRow
diff --git a/src/wuttapos/db/model/batch/__init__.py b/src/wuttapos/db/model/batch/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/wuttapos/db/model/batch/pos.py b/src/wuttapos/db/model/batch/pos.py
new file mode 100644
index 0000000..0ca3b92
--- /dev/null
+++ b/src/wuttapos/db/model/batch/pos.py
@@ -0,0 +1,412 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+Model for POS Batches
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+from sqlalchemy.ext.declarative import declared_attr
+from sqlalchemy.ext.orderinglist import ordering_list
+
+from wuttjamaican.db import model
+
+
+class POSBatch(model.BatchMixin, model.Base):
+ """
+ Hopefully generic batch used for entering new purchases into the system, etc.?
+ """
+
+ __tablename__ = "batch_pos"
+ __wutta_hint__ = {
+ "model_title": "POS Batch",
+ "model_title_plural": "POS Batches",
+ }
+
+ batch_type = "pos"
+
+ STATUS_OK = 1
+ STATUS_SUSPENDED = 2
+
+ STATUS = {
+ STATUS_OK: "ok",
+ STATUS_SUSPENDED: "suspended",
+ }
+
+ store_uuid = model.uuid_fk_column("store.uuid", nullable=False)
+ store = orm.relationship(
+ "Store",
+ doc="""
+ Reference to the store where the transaction ocurred.
+ """,
+ )
+
+ terminal_uuid = model.uuid_fk_column("terminal.uuid", nullable=False)
+ terminal = orm.relationship(
+ "Terminal",
+ doc="""
+ Reference to the terminal where the transaction ocurred.
+ """,
+ )
+
+ # receipt_number = sa.Column(sa.String(length=20), nullable=True, doc="""
+ # Receipt number for the transaction, if known.
+ # """)
+
+ cashier_uuid = model.uuid_fk_column("employee.uuid", nullable=False)
+ cashier = orm.relationship(
+ "Employee",
+ doc="""
+ Reference to the employee who acted as cashier.
+ """,
+ )
+
+ customer_uuid = model.uuid_fk_column("customer.uuid", nullable=True)
+ customer = orm.relationship(
+ "Customer",
+ doc="""
+ Reference to the customer account for the transaction.
+ """,
+ )
+
+ customer_is_member = sa.Column(
+ sa.Boolean(),
+ nullable=True,
+ doc="""
+ Flag indicating the customer was a "member" at time of sale.
+ """,
+ )
+
+ customer_is_employee = sa.Column(
+ sa.Boolean(),
+ nullable=True,
+ doc="""
+ Flag indicating the customer was an employee at time of sale.
+ """,
+ )
+
+ # shopper_number = sa.Column(sa.String(length=20), nullable=True, doc="""
+ # Number of the shopper account for the transaction, if applicable.
+ # """)
+
+ # shopper_name = sa.Column(sa.String(length=255), nullable=True, doc="""
+ # Name of the shopper account for the transaction, if applicable.
+ # """)
+
+ # shopper_uuid = sa.Column(sa.String(length=32), nullable=True)
+ # shopper = orm.relationship(
+ # 'CustomerShopper',
+ # doc="""
+ # Reference to the shopper account for the transaction.
+ # """)
+
+ sales_total = sa.Column(
+ sa.Numeric(precision=9, scale=2),
+ nullable=True,
+ doc="""
+ Sales total for the transaction.
+ """,
+ )
+
+ fs_total = sa.Column(
+ sa.Numeric(precision=9, scale=2),
+ nullable=True,
+ doc="""
+ Portion of the sales total which is foodstamp-eligible.
+ """,
+ )
+
+ tax_total = sa.Column(
+ sa.Numeric(precision=9, scale=2),
+ nullable=True,
+ doc="""
+ Tax total for the transaction.
+ """,
+ )
+
+ fs_tender_total = sa.Column(
+ sa.Numeric(precision=9, scale=2),
+ nullable=True,
+ doc="""
+ Foodstamp tender total for the transaction.
+ """,
+ )
+
+ tender_total = sa.Column(
+ sa.Numeric(precision=9, scale=2),
+ nullable=True,
+ doc="""
+ Tender total for the transaction.
+ """,
+ )
+
+ void = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ default=False,
+ doc="""
+ Flag indicating if the transaction was voided.
+ """,
+ )
+
+ training_mode = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ default=False,
+ doc="""
+ Flag indicating if the transaction was rang in training mode,
+ i.e. not real / should not go on the books.
+ """,
+ )
+
+ def get_balance(self):
+ return (
+ (self.sales_total or 0) + (self.tax_total or 0) + (self.tender_total or 0)
+ )
+
+ def get_fs_balance(self):
+ return (self.fs_total or 0) + (self.fs_tender_total or 0)
+
+
+class POSBatchRow(model.BatchRowMixin, model.Base):
+ """
+ Row of data within a POS batch.
+ """
+
+ __tablename__ = "batch_pos_row"
+ __batch_class__ = POSBatch
+
+ STATUS_OK = 1
+
+ STATUS = {
+ STATUS_OK: "ok",
+ }
+
+ modified_by_uuid = model.uuid_fk_column("user.uuid", nullable=False)
+ modified_by = orm.relationship(
+ "User",
+ doc="""
+ Reference to the user who added this row to the batch.
+ """,
+ )
+
+ row_type = sa.Column(
+ sa.String(length=20),
+ nullable=False,
+ doc="""
+ Type of item represented by this row, e.g. "item" or "return" or
+ "tender" etc.
+
+ .. todo::
+ need to figure out how to manage/track POSBatchRow.row_type
+ """,
+ )
+
+ item_entry = sa.Column(
+ sa.String(length=20),
+ nullable=True,
+ doc="""
+ Raw/original entry value for the item, if applicable.
+ """,
+ )
+
+ description = sa.Column(
+ sa.String(length=100),
+ nullable=True,
+ doc="""
+ Description for the row.
+ """,
+ )
+
+ product_uuid = model.uuid_fk_column("product.uuid", nullable=True)
+ product = orm.relationship(
+ "Product",
+ doc="""
+ Reference to the associated product, if applicable.
+ """,
+ )
+
+ # department_uuid = model.uuid_fk_column("department.uuid", nullable=True)
+ # department = orm.relationship(
+ # "Department",
+ # doc="""
+ # Reference to the associated department, if applicable.
+ # """,
+ # )
+
+ # subdepartment_number = sa.Column(
+ # sa.Integer(),
+ # nullable=True,
+ # doc="""
+ # Number of the subdepartment to which the product belongs.
+ # """,
+ # )
+
+ # subdepartment_name = sa.Column(
+ # sa.String(length=30),
+ # nullable=True,
+ # doc="""
+ # Name of the subdepartment to which the product belongs.
+ # """,
+ # )
+
+ foodstamp_eligible = sa.Column(
+ sa.Boolean(),
+ nullable=True,
+ doc="""
+ Indicates the item is eligible for purchase with food stamps
+ or equivalent.
+ """,
+ )
+
+ sold_by_weight = sa.Column(
+ sa.Boolean(),
+ nullable=True,
+ doc="""
+ Flag indicating the item is sold by weight.
+ """,
+ )
+
+ quantity = sa.Column(
+ sa.Numeric(precision=8, scale=2),
+ nullable=True,
+ doc="""
+ Quantity for the item.
+ """,
+ )
+
+ cost = sa.Column(
+ sa.Numeric(precision=8, scale=3),
+ nullable=True,
+ doc="""
+ Internal cost for the item sold.
+
+ NOTE: this may need to change at some point, hence the "generic"
+ naming so far. would we need to record multiple kinds of costs?
+ """,
+ )
+
+ reg_price = sa.Column(
+ sa.Numeric(precision=8, scale=3),
+ nullable=True,
+ doc="""
+ Regular price for the item.
+ """,
+ )
+
+ cur_price = sa.Column(
+ sa.Numeric(precision=8, scale=3),
+ nullable=True,
+ doc="""
+ Current price for the item.
+ """,
+ )
+
+ cur_price_type = sa.Column(
+ sa.Integer(),
+ nullable=True,
+ doc="""
+ Type code for the current price, if applicable.
+ """,
+ )
+
+ cur_price_start = sa.Column(
+ sa.DateTime(),
+ nullable=True,
+ doc="""
+ Start date for current price, if applicable.
+ """,
+ )
+
+ cur_price_end = sa.Column(
+ sa.DateTime(),
+ nullable=True,
+ doc="""
+ End date for current price, if applicable.
+ """,
+ )
+
+ txn_price = sa.Column(
+ sa.Numeric(precision=8, scale=3),
+ nullable=True,
+ doc="""
+ Actual price paid for the item.
+ """,
+ )
+
+ txn_price_adjusted = sa.Column(
+ sa.Boolean(),
+ nullable=True,
+ doc="""
+ Flag indicating the actual price was manually adjusted.
+ """,
+ )
+
+ sales_total = sa.Column(
+ sa.Numeric(precision=9, scale=2),
+ nullable=True,
+ doc="""
+ Sales total for the item.
+ """,
+ )
+
+ tax_code = sa.Column(
+ sa.String(length=30),
+ nullable=True,
+ doc="""
+ Unique "code" for the item tax rate, if applicable.
+ """,
+ )
+
+ # tax_uuid = model.uuid_fk_column("tax.uuid", nullable=True)
+ # tax = orm.relationship(
+ # "Tax",
+ # doc="""
+ # Reference to the associated tax, if applicable.
+ # """,
+ # )
+
+ tender_total = sa.Column(
+ sa.Numeric(precision=9, scale=2),
+ nullable=True,
+ doc="""
+ Tender total for the item.
+ """,
+ )
+
+ # tender_uuid = model.uuid_fk_column("tender.uuid", nullable=True)
+ # tender = orm.relationship(
+ # "Tender",
+ # doc="""
+ # Reference to the associated tender, if applicable.
+ # """,
+ # )
+
+ void = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ default=False,
+ doc="""
+ Flag indicating the line item was voided.
+ """,
+ )
diff --git a/src/wuttapos/db/model/employees.py b/src/wuttapos/db/model/employees.py
new file mode 100644
index 0000000..f51ad9d
--- /dev/null
+++ b/src/wuttapos/db/model/employees.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8; -*-
+"""
+Model definition for Employees
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class Employee(model.Base):
+ """
+ Represents a current or former employee.
+ """
+
+ __tablename__ = "employee"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Employee",
+ "model_title_plural": "Employees",
+ }
+
+ uuid = model.uuid_column()
+
+ person_uuid = model.uuid_fk_column("person.uuid", nullable=True)
+ person = orm.relationship(
+ "Person",
+ doc="""
+ Reference to the person who is/was the employee.
+ """,
+ backref=orm.backref(
+ "employee",
+ uselist=False,
+ doc="""
+ Reference to the employee record for the person, if applicable.
+ """,
+ ),
+ )
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ doc="""
+ Internal name for the employee.
+ """,
+ )
+
+ public_name = sa.Column(
+ sa.String(length=100),
+ nullable=True,
+ doc="""
+ Name of the employee, for display to the public (if different).
+ """,
+ )
+
+ active = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ default=True,
+ doc="""
+ Indicates the employee is currently active.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttapos/db/model/stores.py b/src/wuttapos/db/model/stores.py
new file mode 100644
index 0000000..a2e957b
--- /dev/null
+++ b/src/wuttapos/db/model/stores.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8; -*-
+"""
+Model definition for Stores
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class Store(model.Base):
+ """
+ Represents a single location, physical or virtual, where sales happen.
+ """
+
+ __tablename__ = "store"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Store",
+ "model_title_plural": "Stores",
+ }
+
+ uuid = model.uuid_column()
+
+ store_id = sa.Column(
+ sa.String(length=20),
+ nullable=False,
+ doc="""
+ Unique identifier for the store.
+ """,
+ )
+
+ name = sa.Column(
+ sa.String(length=100),
+ nullable=False,
+ doc="""
+ Name for the store.
+ """,
+ )
+
+ active = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ default=True,
+ doc="""
+ Indicates the store is currently active.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttapos/db/model/taxes.py b/src/wuttapos/db/model/taxes.py
new file mode 100644
index 0000000..c5418dd
--- /dev/null
+++ b/src/wuttapos/db/model/taxes.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8; -*-
+"""
+Model definition for Taxes
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class Tax(model.Base):
+ """
+ Represents a type/rate of sales tax to track.
+ """
+
+ __tablename__ = "tax"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Tax",
+ "model_title_plural": "Taxes",
+ }
+
+ uuid = model.uuid_column()
+
+ tax_id = sa.Column(
+ sa.String(length=20),
+ nullable=False,
+ doc="""
+ Unique identifier for this tax rate.
+ """,
+ )
+
+ name = sa.Column(
+ sa.String(length=50),
+ nullable=False,
+ doc="""
+ Name for the tax rate.
+ """,
+ )
+
+ rate = sa.Column(
+ sa.Numeric(precision=7, scale=5),
+ nullable=False,
+ doc="""
+ Percentage rate for the tax, e.g. 8.25.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttapos/db/model/tenders.py b/src/wuttapos/db/model/tenders.py
new file mode 100644
index 0000000..ebd8d2a
--- /dev/null
+++ b/src/wuttapos/db/model/tenders.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8; -*-
+"""
+Model definition for Tenders
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class Tender(model.Base):
+ """
+ Represents a tender (payment type) for the POS.
+ """
+
+ __tablename__ = "tender"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Tender",
+ "model_title_plural": "Tenders",
+ }
+
+ uuid = model.uuid_column()
+
+ tender_id = sa.Column(
+ sa.String(length=20),
+ nullable=False,
+ doc="""
+ Unique identifier for the tender.
+ """,
+ )
+
+ name = sa.Column(
+ sa.String(length=50),
+ nullable=False,
+ doc="""
+ Name for the tender type.
+ """,
+ )
+
+ notes = sa.Column(
+ sa.Text(),
+ nullable=True,
+ doc="""
+ Arbitrary notes for the tender type.
+ """,
+ )
+
+ is_cash = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ doc="""
+ Indicates this tender type is a form of "cash" conceptually.
+ """,
+ )
+
+ is_foodstamp = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ doc="""
+ Indicates this tender type is a form of "food stamps" conceptually.
+ """,
+ )
+
+ allow_cashback = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ doc="""
+ Indicates "cash back" should be allowed when overpaying with this tender.
+ """,
+ )
+
+ kick_drawer = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ doc="""
+ Indicates the cash drawer should kick open when accepting this tender.
+ """,
+ )
+
+ active = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ default=True,
+ doc="""
+ Indicates this tender is currently active (acceptable for payment).
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttapos/db/model/terminals.py b/src/wuttapos/db/model/terminals.py
new file mode 100644
index 0000000..8089efa
--- /dev/null
+++ b/src/wuttapos/db/model/terminals.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8; -*-
+"""
+Model definition for Terminals
+"""
+
+import sqlalchemy as sa
+from sqlalchemy import orm
+
+from wuttjamaican.db import model
+
+
+class Terminal(model.Base):
+ """
+ Represents a POS terminal (lane).
+ """
+
+ __tablename__ = "terminal"
+ __versioned__ = {}
+ __wutta_hint__ = {
+ "model_title": "Terminal",
+ "model_title_plural": "Terminals",
+ }
+
+ uuid = model.uuid_column()
+
+ terminal_id = sa.Column(
+ sa.String(length=20),
+ nullable=False,
+ doc="""
+ Unique identifier for the terminal.
+ """,
+ )
+
+ name = sa.Column(
+ sa.String(length=50),
+ nullable=False,
+ doc="""
+ Name for the terminal.
+ """,
+ )
+
+ def __str__(self):
+ return self.name or ""
diff --git a/src/wuttapos/employment.py b/src/wuttapos/employment.py
new file mode 100644
index 0000000..8e41a50
--- /dev/null
+++ b/src/wuttapos/employment.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+Employment handler
+"""
+
+from wuttjamaican.app import GenericHandler
+
+
+class EmploymentHandler(GenericHandler):
+ """
+ Base class and default implementation for employment handlers.
+ """
+
+ def get_employee(self, obj):
+ """
+ Returns the Employee associated with the given object, if any.
+ """
+ model = self.app.model
+
+ if isinstance(obj, model.Employee):
+ employee = obj
+ return employee
+
+ if person := self.app.get_person(obj):
+ if person.employee:
+ return person.employee
+
+ return None
+
+ # def make_employee(self, person):
+ # """
+ # Create and return a new employee record.
+ # """
+ # employee = self.model.Employee()
+ # employee.person = person
+ # return employee
diff --git a/src/wuttapos/enum.py b/src/wuttapos/enum.py
new file mode 100644
index 0000000..fea9c08
--- /dev/null
+++ b/src/wuttapos/enum.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+WuttaPOS enum values
+"""
+
+from collections import OrderedDict
+
+from wuttjamaican.enum import *
+
+
+########################################
+# POS Batch Row Type
+########################################
+
+POS_ROW_TYPE_SET_CUSTOMER = "set_customer"
+POS_ROW_TYPE_SWAP_CUSTOMER = "swap_customer"
+POS_ROW_TYPE_DEL_CUSTOMER = "del_customer"
+POS_ROW_TYPE_SELL = "sell"
+POS_ROW_TYPE_OPEN_RING = "openring"
+POS_ROW_TYPE_BADSCAN = "badscan"
+POS_ROW_TYPE_BADPRICE = "badprice"
+POS_ROW_TYPE_ADJUST_PRICE = "adjust_price"
+POS_ROW_TYPE_VOID_LINE = "void_line"
+POS_ROW_TYPE_VOID_TXN = "void_txn"
+POS_ROW_TYPE_SUSPEND = "suspend"
+POS_ROW_TYPE_RESUME = "resume"
+POS_ROW_TYPE_TENDER = "tender"
+POS_ROW_TYPE_CHANGE_BACK = "change_back"
+
+POS_ROW_TYPE = OrderedDict(
+ [
+ (POS_ROW_TYPE_SET_CUSTOMER, "set customer"),
+ (POS_ROW_TYPE_SWAP_CUSTOMER, "swap customer"),
+ (POS_ROW_TYPE_DEL_CUSTOMER, "del customer"),
+ (POS_ROW_TYPE_SELL, "sell"),
+ (POS_ROW_TYPE_OPEN_RING, "open ring"),
+ (POS_ROW_TYPE_BADSCAN, "bad scan"),
+ (POS_ROW_TYPE_BADPRICE, "bad price"),
+ (POS_ROW_TYPE_ADJUST_PRICE, "adjust price"),
+ (POS_ROW_TYPE_VOID_LINE, "void line"),
+ (POS_ROW_TYPE_VOID_TXN, "void txn"),
+ (POS_ROW_TYPE_SUSPEND, "suspend"),
+ (POS_ROW_TYPE_RESUME, "resume"),
+ (POS_ROW_TYPE_TENDER, "tender"),
+ (POS_ROW_TYPE_CHANGE_BACK, "change back"),
+ ]
+)
diff --git a/src/wuttapos/handler.py b/src/wuttapos/handler.py
deleted file mode 100644
index 9f7f01f..0000000
--- a/src/wuttapos/handler.py
+++ /dev/null
@@ -1,100 +0,0 @@
-# -*- coding: utf-8; -*-
-################################################################################
-#
-# WuttaPOS -- Point of Sale system based on Wutta Framework
-# Copyright © 2026 Lance Edgar
-#
-# This file is part of WuttaPOS.
-#
-# WuttaPOS 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.
-#
-# WuttaPOS 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
-# WuttaPOS. If not, see .
-#
-################################################################################
-"""
-Transaction Handler
-"""
-
-import decimal
-
-import sqlalchemy as sa
-
-from wuttjamaican.app import GenericHandler
-
-
-class TransactionHandler(GenericHandler):
- """
- Base class and default implementation for the :term:`transaction
- handler`.
-
- This is responsible for business logic while a transaction is
- being rang up.
- """
-
- def get_terminal_id(self):
- """
- Returns the ID string for current POS terminal.
- """
- return self.config.get("wuttapos.terminal_id")
-
- def get_screen_txn_display(self, txn, **kwargs):
- """
- Should return the text to be used for displaying transaction
- identifier within the header of POS screen.
- """
- return "-".join([txn["terminal_id"], txn["cashier_id"], txn["transaction_id"]])
-
- def get_screen_cust_display(self, txn=None, customer=None, **kwargs):
- """
- Should return the text to be used for displaying customer
- identifier / name etc. within the header of POS screen.
- """
-
- if not customer and txn:
- return txn["customer_id"]
-
- if not customer:
- return
-
- # TODO: what about person_number
- return str(customer.card_number)
-
- # TODO: should also filter this by terminal?
- def get_current_transaction(
- self,
- user,
- # terminal_id=None,
- training_mode=False,
- # create=True,
- return_created=False,
- **kwargs,
- ):
- """
- Get the "current" POS transaction for the given user, creating
- it as needed.
-
- :param training_mode: Flag indicating whether the transaction
- should be in training mode. The lookup will be restricted
- according to the value of this flag. If a new batch is
- created, it will be assigned this flag value.
- """
- if not user:
- raise ValueError("must specify a user")
-
- # TODO
- created = False
- lines = []
- if not lines:
- # if not create:
- if return_created:
- return None, False
- return None
diff --git a/src/wuttapos/people.py b/src/wuttapos/people.py
new file mode 100644
index 0000000..ed68c3c
--- /dev/null
+++ b/src/wuttapos/people.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+People Handler
+
+This is a :term:`handler` to manage "people" in the DB.
+"""
+
+from wuttjamaican import people as base
+
+
+class PeopleHandler(base.PeopleHandler):
+ """
+ TODO
+ """
+
+ def get_person(self, obj):
+ model = self.app.model
+
+ # upstream logic may be good enough
+ if person := super().get_person(obj):
+ return person
+
+ # employee
+ if isinstance(obj, model.Employee):
+ employee = obj
+ return employee.person
+
+ return None
diff --git a/src/wuttapos/products.py b/src/wuttapos/products.py
new file mode 100644
index 0000000..6c1aae0
--- /dev/null
+++ b/src/wuttapos/products.py
@@ -0,0 +1,518 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+Products Handler
+"""
+
+# import decimal
+import logging
+import uuid as _uuid
+
+from sqlalchemy import orm
+
+from wuttjamaican.app import GenericHandler
+
+
+log = logging.getLogger(__name__)
+
+
+class ProductsHandler(GenericHandler):
+ """
+ Base class and default implementation for product handlers.
+
+ A products handler of course should get the final say in how products are
+ handled. This means everything from pricing, to whether or not a
+ particular product can be deleted, etc.
+ """
+
+ def locate_product_for_entry(
+ self, session, entry, include_not_for_sale=False, **kwargs
+ ):
+ """
+ This method aims to provide sane default logic for locating a
+ :class:`~rattail.db.model.products.Product` record for the
+ given "entry" value.
+
+ The default logic here will try to honor the "configured"
+ product key field, and prefer that when attempting the lookup.
+
+ :param session: Reference to current DB session.
+
+ :param entry: Value to use for lookup. This is most often a
+ simple string, but the method can handle a few others. For
+ instance it is common to read values from a spreadsheet,
+ and sometimes those come through as integers etc. If this
+ value is a :class:`~rattail.gpc.GPC` instance, special
+ logic may be used for the lookup.
+
+ :param lookup_fields: Optional list of fields to use for
+ lookup. The default value is ``['uuid', '_product_key_']``
+ which means to lookup by UUID as well as "product key"
+ field, which is configurable. You can include any of the
+ following in ``lookup_fields``:
+
+ * ``uuid``
+ * ``_product_key_`` - :meth:`locate_product_for_key`
+ * ``upc`` - :meth:`locate_product_for_upc`
+ * ``item_id`` - :meth:`locate_product_for_item_id`
+ * ``scancode`` - :meth:`locate_product_for_scancode`
+ * ``vendor_code`` - :meth:`locate_product_for_vendor_code`
+ * ``alt_code`` - :meth:`locate_product_for_alt_code`
+
+ :param include_not_for_sale: Optional flag to include items
+ which are "not for sale" in the search results.
+
+ :returns: First :class:`~rattail.db.model.products.Product`
+ instance found if there was a match; otherwise ``None``.
+ """
+ model = self.app.model
+ if not entry:
+ return
+
+ # figure out which fields we should match on
+ # TODO: let config declare default lookup_fields
+ lookup_fields = kwargs.pop(
+ "lookup_fields",
+ [
+ "uuid",
+ "product_id",
+ ],
+ )
+
+ kwargs["include_not_for_sale"] = include_not_for_sale
+
+ # try to locate product by uuid before other, more specific key
+ if "uuid" in lookup_fields:
+ if isinstance(entry, (_uuid.UUID, str)):
+ if product := session.get(model.Product, entry):
+ return product
+ # # TODO: should we ever return deleted product?
+ # if product and not product.deleted:
+ # if include_not_for_sale or not product.not_for_sale:
+ # return product
+
+ lookups = {
+ "uuid": None,
+ "product_id": self.locate_product_for_id,
+ # "upc": self.locate_product_for_upc,
+ # "item_id": self.locate_product_for_item_id,
+ # "scancode": self.locate_product_for_scancode,
+ # "vendor_code": self.locate_product_for_vendor_code,
+ # "alt_code": self.locate_product_for_alt_code,
+ }
+
+ for field in lookup_fields:
+ if field in lookups:
+ lookup = lookups[field]
+ if lookup:
+ product = lookup(session, entry, **kwargs)
+ if product:
+ return product
+ else:
+ log.warning("unknown lookup field: %s", field)
+
+ def locate_product_for_id(
+ self,
+ session,
+ entry,
+ # include_not_for_sale=False,
+ # include_deleted=False,
+ **kwargs,
+ ):
+ """
+ Locate the product which matches the given item ID.
+
+ This will do a lookup on the
+ :attr:`rattail.db.model.products.Product.item_id` field only.
+
+ Note that instead of calling this method directly, you might
+ consider calling :meth:`locate_product_for_key` instead.
+
+ :param session: Current session for Rattail DB.
+
+ :param entry: Item ID value as string.
+
+ :param include_not_for_sale: Optional flag to include items
+ which are "not for sale" in the search results.
+
+ :param include_deleted: Whether "deleted" products should ever
+ match (and be returned).
+
+ :returns: First :class:`~rattail.db.model.products.Product`
+ instance found if there was a match; otherwise ``None``.
+ """
+ if not entry:
+ return
+
+ # assume entry is string
+ entry = str(entry)
+
+ model = self.app.model
+ products = session.query(model.Product)
+ # if not include_deleted:
+ # products = products.filter(model.Product.deleted == False)
+ # if not include_not_for_sale:
+ # products = products.filter(model.Product.not_for_sale == False)
+
+ try:
+ return products.filter(model.Product.product_id == entry).one()
+ except orm.exc.NoResultFound:
+ return None
+
+ def search_products(self, session, entry, **kwargs):
+ """
+ Perform a product search across multiple fields, and return
+ results as JSON data rows.
+ """
+ model = self.app.model
+ final_results = []
+
+ # first we'll attempt "lookup" logic..
+
+ lookup_fields = kwargs.get(
+ "lookup_fields",
+ [
+ # "_product_key_",
+ "product_id"
+ ],
+ )
+
+ if lookup_fields:
+ product = self.locate_product_for_entry(
+ session, entry, lookup_fields=lookup_fields
+ )
+ if product:
+ final_results.append(product)
+
+ # then we'll attempt "search" logic..
+
+ search_fields = kwargs.get(
+ "search_fields",
+ [
+ "product_id",
+ "brand",
+ "description",
+ "size",
+ ],
+ )
+
+ searches = {
+ "product_id": self.search_products_for_product_id,
+ "brand": self.search_products_for_brand,
+ "description": self.search_products_for_description,
+ "size": self.search_products_for_size,
+ }
+
+ for field in search_fields:
+ if field in searches:
+ search = searches[field]
+ if search:
+ products = search(session, entry, **kwargs)
+ final_results.extend(products)
+ else:
+ log.warning("unknown search field: %s", field)
+
+ return [self.normalize_product(c) for c in final_results]
+
+ def search_products_for_product_id(
+ self,
+ session,
+ entry,
+ # include_not_for_sale=False,
+ ):
+ """
+ Search for products where the
+ :attr:`~rattail.db.model.products.Product.item_id` contains
+ the given value.
+
+ :param entry: Search term.
+
+ :param include_not_for_sale: Optional flag to include items
+ which are "not for sale" in the search results.
+
+ :returns: List of products matching the search.
+ """
+ model = self.app.model
+ entry = entry.lower()
+
+ products = session.query(model.Product).filter(
+ model.Product.product_id.ilike(f"%{entry}%")
+ )
+
+ # if not include_not_for_sale:
+ # products = products.filter(model.Product.not_for_sale == False)
+
+ return products.all()
+
+ def search_products_for_brand(
+ self,
+ session,
+ entry,
+ # include_not_for_sale=False,
+ ):
+ """
+ Search for products where the brand
+ :attr:`~rattail.db.model.products.Brand.name` contains the
+ given value.
+
+ :param entry: Search term.
+
+ :param include_not_for_sale: Optional flag to include items
+ which are "not for sale" in the search results.
+
+ :returns: List of products matching the search.
+ """
+ model = self.app.model
+ entry = entry.lower()
+
+ products = session.query(model.Product).filter(
+ model.Product.brand_name.ilike(f"%{entry}%")
+ )
+
+ # if not include_not_for_sale:
+ # products = products.filter(model.Product.not_for_sale == False)
+
+ return products.all()
+
+ def search_products_for_description(
+ self,
+ session,
+ entry,
+ # include_not_for_sale=False,
+ ):
+ """
+ Search for products where the
+ :attr:`~rattail.db.model.products.Product.description`
+ contains the given value.
+
+ :param entry: Search term.
+
+ :param include_not_for_sale: Optional flag to include items
+ which are "not for sale" in the search results.
+
+ :returns: List of products matching the search.
+ """
+ model = self.app.model
+ entry = entry.lower()
+
+ products = session.query(model.Product).filter(
+ model.Product.description.ilike(f"%{entry}%")
+ )
+
+ # if not include_not_for_sale:
+ # products = products.filter(model.Product.not_for_sale == False)
+
+ return products.all()
+
+ def search_products_for_size(
+ self,
+ session,
+ entry,
+ # include_not_for_sale=False,
+ ):
+ """
+ Search for products where the
+ :attr:`~rattail.db.model.products.Product.size` contains the
+ given value.
+
+ :param entry: Search term.
+
+ :param include_not_for_sale: Optional flag to include items
+ which are "not for sale" in the search results.
+
+ :returns: List of products matching the search.
+ """
+ model = self.app.model
+ entry = entry.lower()
+
+ products = session.query(model.Product).filter(
+ model.Product.size.ilike(f"%{entry}%")
+ )
+
+ # if not include_not_for_sale:
+ # products = products.filter(model.Product.not_for_sale == False)
+
+ return products.all()
+
+ def normalize_product(self, product, fields=None):
+ """
+ Normalize the given product to a JSON-serializable dict.
+ """
+ data = {
+ "uuid": product.uuid,
+ "product_id": product.product_id,
+ "description": product.description,
+ "size": product.size,
+ "_str": str(product),
+ }
+
+ if not fields:
+ fields = [
+ "brand_name",
+ "full_description",
+ "department_name",
+ "unit_price_display",
+ ]
+
+ if "brand_name" in fields:
+ data["brand_name"] = product.brand_name
+
+ if "full_description" in fields:
+ data["full_description"] = product.full_description
+
+ if "department_name" in fields:
+ data["department_name"] = (
+ product.department.name if product.department else None
+ )
+
+ if "unit_price_display" in fields:
+ data["unit_price_display"] = self.app.render_currency(
+ product.unit_price_reg
+ )
+
+ # if "vendor_name" in fields:
+ # vendor = product.cost.vendor if product.cost else None
+ # data["vendor_name"] = vendor.name if vendor else None
+
+ # if "costs" in fields:
+ # costs = []
+ # for cost in product.costs:
+ # costs.append(
+ # {
+ # "uuid": cost.uuid,
+ # "vendor_uuid": cost.vendor_uuid,
+ # "vendor_name": cost.vendor.name,
+ # "preference": cost.preference,
+ # "code": cost.code,
+ # "case_size": cost.case_size,
+ # "case_cost": cost.case_cost,
+ # "unit_cost": cost.unit_cost,
+ # }
+ # )
+ # data["costs"] = costs
+
+ # current_price = None
+ # if not product.not_for_sale:
+
+ # margin_fields = [
+ # "true_margin",
+ # "true_margin_display",
+ # ]
+ # if any([f in fields for f in margin_fields]):
+ # if product.volatile:
+ # data["true_margin"] = product.volatile.true_margin
+ # data["true_margin_display"] = self.app.render_percent(
+ # product.volatile.true_margin, places=2
+ # )
+
+ # current_fields = [
+ # "current_price",
+ # "current_price_display",
+ # "current_ends",
+ # "current_ends_display",
+ # ]
+ # if any([f in fields for f in current_fields]):
+ # current_price = product.current_price
+ # if current_price:
+ # if current_price.price:
+ # data["current_price"] = float(current_price.price)
+ # data["current_price_display"] = self.render_price(current_price)
+ # current_ends = current_price.ends
+ # if current_ends:
+ # current_ends = self.app.localtime(
+ # current_ends, from_utc=True
+ # ).date()
+ # data["current_ends"] = str(current_ends)
+ # data["current_ends_display"] = self.app.render_date(
+ # current_ends
+ # )
+
+ # sale_fields = [
+ # "sale_price",
+ # "sale_price_display",
+ # "sale_ends",
+ # "sale_ends_display",
+ # ]
+ # if any([f in fields for f in sale_fields]):
+ # sale_price = product.sale_price
+ # if sale_price:
+ # if sale_price.price:
+ # data["sale_price"] = float(sale_price.price)
+ # data["sale_price_display"] = self.render_price(sale_price)
+ # sale_ends = sale_price.ends
+ # if sale_ends:
+ # sale_ends = self.app.localtime(sale_ends, from_utc=True).date()
+ # data["sale_ends"] = str(sale_ends)
+ # data["sale_ends_display"] = self.app.render_date(sale_ends)
+
+ # tpr_fields = [
+ # "tpr_price",
+ # "tpr_price_display",
+ # "tpr_ends",
+ # "tpr_ends_display",
+ # ]
+ # if any([f in fields for f in tpr_fields]):
+ # tpr_price = product.tpr_price
+ # if tpr_price:
+ # if tpr_price.price:
+ # data["tpr_price"] = float(tpr_price.price)
+ # data["tpr_price_display"] = self.render_price(tpr_price)
+ # tpr_ends = tpr_price.ends
+ # if tpr_ends:
+ # tpr_ends = self.app.localtime(tpr_ends, from_utc=True).date()
+ # data["tpr_ends"] = str(tpr_ends)
+ # data["tpr_ends_display"] = self.app.render_date(tpr_ends)
+
+ if "case_size" in fields:
+ data["case_size"] = self.app.render_quantity(self.get_case_size(product))
+
+ # if "case_price" in fields or "case_price_display" in fields:
+ # case_price = None
+ # if product.regular_price and product.regular_price is not None:
+ # case_size = self.get_case_size(product)
+ # # use "current" price if there is one, else normal unit price
+ # unit_price = product.regular_price.price
+ # if current_price:
+ # unit_price = current_price.price
+ # case_price = (case_size or 1) * unit_price
+ # case_price = case_price.quantize(decimal.Decimal("0.01"))
+ # data["case_price"] = str(case_price) if case_price is not None else None
+ # data["case_price_display"] = self.app.render_currency(case_price)
+
+ # if "uom_choices" in fields:
+ # data["uom_choices"] = self.get_uom_choices(product)
+
+ return data
+
+ def get_case_size(self, product):
+ """
+ Return the effective case size for the given product.
+ """
+ if product.case_size is not None:
+ return product.case_size
+
+ # cost = product.cost
+ # if cost:
+ # return cost.case_size
+
+ return None
diff --git a/src/wuttapos/server/forms/schema.py b/src/wuttapos/server/forms/schema.py
index dc040d1..d365bc0 100644
--- a/src/wuttapos/server/forms/schema.py
+++ b/src/wuttapos/server/forms/schema.py
@@ -27,6 +27,50 @@ Form schema types
from wuttaweb.forms.schema import ObjectRef
+class StoreRef(ObjectRef):
+ """
+ Schema type for a
+ :class:`~wuttapos.db.model.stores.Store` reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self):
+ model = self.app.model
+ return model.Store
+
+ def sort_query(self, query):
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, obj):
+ store = obj
+ return self.request.route_url("stores.view", uuid=store.uuid)
+
+
+class TerminalRef(ObjectRef):
+ """
+ Schema type for a
+ :class:`~wuttapos.db.model.terminals.Terminal` reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self):
+ model = self.app.model
+ return model.Terminal
+
+ def sort_query(self, query):
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, obj):
+ terminal = obj
+ return self.request.route_url("terminals.view", uuid=terminal.uuid)
+
+
class DepartmentRef(ObjectRef):
"""
Schema type for a
@@ -49,6 +93,50 @@ class DepartmentRef(ObjectRef):
return self.request.route_url("departments.view", uuid=department.uuid)
+class EmployeeRef(ObjectRef):
+ """
+ Schema type for a
+ :class:`~wuttapos.db.model.employees.Employee` reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self):
+ model = self.app.model
+ return model.Employee
+
+ def sort_query(self, query):
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, obj):
+ employee = obj
+ return self.request.route_url("employees.view", uuid=employee.uuid)
+
+
+class CustomerRef(ObjectRef):
+ """
+ Schema type for a
+ :class:`~wuttapos.db.model.customers.Customer` reference field.
+
+ This is a subclass of
+ :class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
+ """
+
+ @property
+ def model_class(self):
+ model = self.app.model
+ return model.Customer
+
+ def sort_query(self, query):
+ return query.order_by(self.model_class.name)
+
+ def get_object_url(self, obj):
+ customer = obj
+ return self.request.route_url("customers.view", uuid=customer.uuid)
+
+
class ProductRef(ObjectRef):
"""
Schema type for a
diff --git a/src/wuttapos/server/menus.py b/src/wuttapos/server/menus.py
index a007fb2..6f29f10 100644
--- a/src/wuttapos/server/menus.py
+++ b/src/wuttapos/server/menus.py
@@ -33,19 +33,16 @@ class WuttaPosMenuHandler(base.MenuHandler):
"""
def make_menus(self, request, **kwargs):
-
- # nb. the products menu is just an example; you should
- # replace it and add more as needed
-
return [
- self.make_customers_menu(request),
+ self.make_people_menu(request),
self.make_products_menu(request),
- self.make_admin_menu(request, include_people=True),
+ self.make_batches_menu(request),
+ self.make_admin_menu(request),
]
- def make_customers_menu(self, request):
+ def make_people_menu(self, request):
return {
- "title": "Customers",
+ "title": "People",
"type": "menu",
"items": [
{
@@ -53,6 +50,16 @@ class WuttaPosMenuHandler(base.MenuHandler):
"route": "customers",
"perm": "customers.list",
},
+ {
+ "title": "Employees",
+ "route": "employees",
+ "perm": "employees.list",
+ },
+ {
+ "title": "All People",
+ "route": "people",
+ "perm": "people.list",
+ },
],
}
@@ -95,3 +102,46 @@ class WuttaPosMenuHandler(base.MenuHandler):
# },
],
}
+
+ def make_batches_menu(self, request):
+ return {
+ "title": "Batches",
+ "type": "menu",
+ "items": [
+ {
+ "title": "POS",
+ "route": "batch.pos",
+ "perm": "batch.pos.list",
+ },
+ ],
+ }
+
+ def make_admin_menu(self, request, **kwargs):
+ kwargs.setdefault("include_people", False)
+ menu = super().make_admin_menu(request, **kwargs)
+
+ menu["items"] = [
+ {
+ "title": "Stores",
+ "route": "stores",
+ "perm": "stores.list",
+ },
+ {
+ "title": "Terminals",
+ "route": "terminals",
+ "perm": "terminals.list",
+ },
+ {
+ "title": "Tenders",
+ "route": "tenders",
+ "perm": "tenders.list",
+ },
+ {
+ "title": "Taxes",
+ "route": "taxes",
+ "perm": "taxes.list",
+ },
+ {"type": "sep"},
+ ] + menu["items"]
+
+ return menu
diff --git a/src/wuttapos/server/views/__init__.py b/src/wuttapos/server/views/__init__.py
index c70be9d..b2d5f29 100644
--- a/src/wuttapos/server/views/__init__.py
+++ b/src/wuttapos/server/views/__init__.py
@@ -31,10 +31,16 @@ def includeme(config):
config.include("wuttaweb.views.essential")
# wuttapos
+ config.include("wuttapos.server.views.stores")
+ config.include("wuttapos.server.views.terminals")
+ config.include("wuttapos.server.views.employees")
+ config.include("wuttapos.server.views.tenders")
+ config.include("wuttapos.server.views.taxes")
config.include("wuttapos.server.views.departments")
config.include("wuttapos.server.views.products")
config.include("wuttapos.server.views.inventory_adjustments")
config.include("wuttapos.server.views.customers")
+ config.include("wuttapos.server.views.batch.pos")
# TODO: these should probably live elsewhere?
config.add_wutta_permission_group("pos", "POS", overwrite=False)
diff --git a/src/wuttapos/server/views/batch/__init__.py b/src/wuttapos/server/views/batch/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/wuttapos/server/views/batch/pos.py b/src/wuttapos/server/views/batch/pos.py
new file mode 100644
index 0000000..d8c3020
--- /dev/null
+++ b/src/wuttapos/server/views/batch/pos.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttaPOS -- Point of Sale system based on Wutta Framework
+# Copyright © 2026 Lance Edgar
+#
+# This file is part of WuttaPOS.
+#
+# WuttaPOS 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.
+#
+# WuttaPOS 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
+# WuttaPOS. If not, see .
+#
+################################################################################
+"""
+Master view for POS Batches
+"""
+
+from wuttaweb.views.batch import BatchMasterView
+
+from wuttapos.db.model import POSBatch
+from wuttapos.server.forms.schema import StoreRef, TerminalRef, EmployeeRef, CustomerRef
+
+
+class POSBatchView(BatchMasterView):
+ """
+ Master view for POS Batches
+ """
+
+ model_class = POSBatch
+
+ route_prefix = "batch.pos"
+ url_prefix = "/batch/pos"
+
+ creatable = False
+ editable = False
+ # nb. allow delete for now, at least is useful in dev?
+ deletable = True
+ executable = False
+
+ labels = {
+ "terminal_id": "Terminal ID",
+ }
+
+ grid_columns = [
+ "id",
+ "created",
+ "store",
+ "terminal",
+ "cashier",
+ "customer",
+ "row_count",
+ "sales_total",
+ "void",
+ "training_mode",
+ "status_code",
+ "executed",
+ ]
+
+ form_fields = [
+ "id",
+ "terminal",
+ "cashier",
+ "customer",
+ "customer_is_member",
+ "customer_is_employee",
+ "params",
+ "row_count",
+ "sales_total",
+ # 'taxes',
+ "tender_total",
+ "fs_tender_total",
+ "balance",
+ "void",
+ "training_mode",
+ "status_code",
+ "created",
+ "created_by",
+ "executed",
+ "executed_by",
+ ]
+
+ filter_defaults = {
+ "executed": {"active": True, "verb": "is_null"},
+ }
+
+ row_grid_columns = [
+ "sequence",
+ "row_type",
+ "item_entry",
+ "description",
+ "product",
+ "reg_price",
+ "txn_price",
+ "quantity",
+ "sales_total",
+ "tender_total",
+ "tax_code",
+ "modified_by",
+ ]
+
+ def get_batch_handler(self):
+ """
+ Must return the :term:`batch handler` for use with this view.
+
+ There is no default logic; subclass must override.
+ """
+ spec = "wuttapos.batch.pos:POSBatchHandler"
+ factory = self.app.load_object(spec)
+ return factory(self.config)
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # links
+ g.set_link("terminal")
+ g.set_link("cashier")
+ g.set_link("customer")
+
+ def grid_row_class(self, batch, data, i):
+ if batch.training_mode:
+ return "has-background-warning"
+ if batch.void:
+ return "has-background-danger-light"
+ return None
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # store
+ f.set_node("store", StoreRef(self.request))
+
+ # terminal
+ f.set_node("terminal", TerminalRef(self.request))
+
+ # cashier
+ f.set_node("cashier", EmployeeRef(self.request))
+
+ # customer
+ f.set_node("customer", CustomerRef(self.request))
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ POSBatchView = kwargs.get("POSBatchView", base["POSBatchView"])
+ POSBatchView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttapos/server/views/employees.py b/src/wuttapos/server/views/employees.py
new file mode 100644
index 0000000..4fd51ff
--- /dev/null
+++ b/src/wuttapos/server/views/employees.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8; -*-
+"""
+Master view for Employees
+"""
+
+from wuttaweb.views import MasterView
+from wuttaweb.forms.schema import PersonRef
+
+from wuttapos.db.model import Employee
+
+
+class EmployeeView(MasterView):
+ """
+ Master view for Employees
+ """
+
+ model_class = Employee
+ model_title = "Employee"
+ model_title_plural = "Employees"
+
+ route_prefix = "employees"
+ url_prefix = "/employees"
+
+ creatable = True
+ editable = True
+ deletable = True
+
+ grid_columns = [
+ "person",
+ "name",
+ "public_name",
+ "active",
+ ]
+
+ form_fields = [
+ "person",
+ "name",
+ "public_name",
+ "active",
+ ]
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # person
+ f.set_node("person", PersonRef(self.request))
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ EmployeeView = kwargs.get("EmployeeView", base["EmployeeView"])
+ EmployeeView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttapos/server/views/stores.py b/src/wuttapos/server/views/stores.py
new file mode 100644
index 0000000..66a55fc
--- /dev/null
+++ b/src/wuttapos/server/views/stores.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8; -*-
+"""
+Master view for Stores
+"""
+
+from wuttapos.db.model.stores import Store
+
+from wuttaweb.views import MasterView
+
+
+class StoreView(MasterView):
+ """
+ Master view for Stores
+ """
+
+ model_class = Store
+ model_title = "Store"
+ model_title_plural = "Stores"
+
+ route_prefix = "stores"
+ url_prefix = "/stores"
+
+ creatable = True
+ editable = True
+ deletable = True
+
+ labels = {
+ "store_id": "Store ID",
+ }
+
+ grid_columns = [
+ "store_id",
+ "name",
+ "active",
+ ]
+
+ form_fields = [
+ "store_id",
+ "name",
+ "active",
+ ]
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ StoreView = kwargs.get("StoreView", base["StoreView"])
+ StoreView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttapos/server/views/taxes.py b/src/wuttapos/server/views/taxes.py
new file mode 100644
index 0000000..195db50
--- /dev/null
+++ b/src/wuttapos/server/views/taxes.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8; -*-
+"""
+Master view for Taxes
+"""
+
+from wuttapos.db.model.taxes import Tax
+
+from wuttaweb.views import MasterView
+
+
+class TaxView(MasterView):
+ """
+ Master view for Taxes
+ """
+
+ model_class = Tax
+ model_title = "Tax"
+ model_title_plural = "Taxes"
+
+ route_prefix = "taxes"
+ url_prefix = "/taxes"
+
+ creatable = True
+ editable = True
+ deletable = True
+
+ labels = {
+ "tax_id": "Tax ID",
+ }
+
+ grid_columns = [
+ "tax_id",
+ "name",
+ "rate",
+ ]
+
+ form_fields = [
+ "tax_id",
+ "name",
+ "rate",
+ ]
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ TaxView = kwargs.get("TaxView", base["TaxView"])
+ TaxView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttapos/server/views/tenders.py b/src/wuttapos/server/views/tenders.py
new file mode 100644
index 0000000..bb77595
--- /dev/null
+++ b/src/wuttapos/server/views/tenders.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8; -*-
+"""
+Master view for Tenders
+"""
+
+from wuttapos.db.model.tenders import Tender
+
+from wuttaweb.views import MasterView
+
+
+class TenderView(MasterView):
+ """
+ Master view for Tenders
+ """
+
+ model_class = Tender
+ model_title = "Tender"
+ model_title_plural = "Tenders"
+
+ route_prefix = "tenders"
+ url_prefix = "/tenders"
+
+ creatable = True
+ editable = True
+ deletable = True
+
+ labels = {
+ "tender_id": "Tender ID",
+ }
+
+ grid_columns = [
+ "tender_id",
+ "name",
+ "is_cash",
+ "is_foodstamp",
+ "allow_cashback",
+ "kick_drawer",
+ "active",
+ ]
+
+ form_fields = [
+ "tender_id",
+ "name",
+ "notes",
+ "is_cash",
+ "is_foodstamp",
+ "allow_cashback",
+ "kick_drawer",
+ "active",
+ ]
+
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # links
+ g.set_link("tender_id")
+ g.set_link("name")
+
+ def configure_form(self, form):
+ f = form
+ super().configure_form(f)
+
+ # notes
+ f.set_widget("notes", "notes")
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ TenderView = kwargs.get("TenderView", base["TenderView"])
+ TenderView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttapos/server/views/terminals.py b/src/wuttapos/server/views/terminals.py
new file mode 100644
index 0000000..2e6a456
--- /dev/null
+++ b/src/wuttapos/server/views/terminals.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8; -*-
+"""
+Master view for Terminals
+"""
+
+from wuttapos.db.model.terminals import Terminal
+
+from wuttaweb.views import MasterView
+
+
+class TerminalView(MasterView):
+ """
+ Master view for Terminals
+ """
+
+ model_class = Terminal
+ model_title = "Terminal"
+ model_title_plural = "Terminals"
+
+ route_prefix = "terminals"
+ url_prefix = "/terminals"
+
+ creatable = True
+ editable = True
+ deletable = True
+
+ labels = {
+ "terminal_id": "Terminal ID",
+ }
+
+ grid_columns = [
+ "terminal_id",
+ "name",
+ ]
+
+ form_fields = [
+ "terminal_id",
+ "name",
+ ]
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ TerminalView = kwargs.get("TerminalView", base["TerminalView"])
+ TerminalView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttapos/terminal/app.py b/src/wuttapos/terminal/app.py
index 0fafd5c..a67efca 100644
--- a/src/wuttapos/terminal/app.py
+++ b/src/wuttapos/terminal/app.py
@@ -38,6 +38,7 @@ import flet as ft
import wuttapos
from wuttapos.terminal.controls.buttons import make_button
+from wuttapos.terminal.util import get_pos_batch_handler
log = logging.getLogger(__name__)
@@ -47,7 +48,7 @@ def main(page: ft.Page):
config = make_config()
app = config.get_app()
model = app.model
- handler = app.get_transaction_handler()
+ handler = get_pos_batch_handler(config)
# nb. as of python 3.10 the original hook is accessible, we needn't save the ref
# cf. https://docs.python.org/3/library/threading.html#threading.__excepthook__
@@ -264,11 +265,10 @@ def main(page: ft.Page):
page.session.set("user_uuid", user.uuid.hex)
page.session.set("user_display", str(user))
- txn = handler.get_current_transaction(user, create=False)
- if txn:
- page.session.set("txn_display", handler.get_screen_txn_display(txn))
- if txn["customer_id"]:
- page.session.set("cust_uuid", txn["customer_id"])
+ if batch := handler.get_current_batch(user, create=False):
+ page.session.set("txn_display", batch.id_str)
+ if batch.customer:
+ page.session.set("cust_uuid", batch.customer.uuid)
page.session.set(
"cust_display", handler.get_screen_cust_display(txn=txn)
)
diff --git a/src/wuttapos/terminal/controls/custlookup.py b/src/wuttapos/terminal/controls/custlookup.py
index 8803ecb..4b0d4b8 100644
--- a/src/wuttapos/terminal/controls/custlookup.py
+++ b/src/wuttapos/terminal/controls/custlookup.py
@@ -31,7 +31,7 @@ class WuttaCustomerLookup(WuttaLookup):
def get_results_columns(self):
return [
- self.app.get_customer_key_label(),
+ "Customer ID",
"Name",
"Phone",
"Email",
@@ -42,7 +42,7 @@ class WuttaCustomerLookup(WuttaLookup):
def make_result_row(self, customer):
return [
- customer["_customer_key_"],
+ customer["customer_id"],
customer["name"],
customer["phone_number"],
customer["email_address"],
diff --git a/src/wuttapos/terminal/controls/deptlookup.py b/src/wuttapos/terminal/controls/deptlookup.py
index f0b12b0..c3785f5 100644
--- a/src/wuttapos/terminal/controls/deptlookup.py
+++ b/src/wuttapos/terminal/controls/deptlookup.py
@@ -39,38 +39,30 @@ class WuttaDepartmentLookup(WuttaLookup):
def get_results_columns(self):
return [
- "Number",
+ "Department ID",
"Name",
]
def get_results(self, session, entry):
- corepos = self.app.get_corepos_handler()
- op_model = corepos.get_model_lane_op()
- op_session = corepos.make_session_lane_op()
-
- query = op_session.query(op_model.Department).order_by(
- op_model.Department.number
- )
+ model = self.app.model
+ query = session.query(model.Department).order_by(model.Department.name)
if entry:
- query = query.filter(op_model.Department.name.ilike(f"%{entry}%"))
+ query = query.filter(model.Department.name.ilike(f"%{entry}%"))
departments = []
for dept in query:
departments.append(
{
- # TODO
- # 'uuid': dept.uuid,
- "uuid": str(dept.number),
- "number": str(dept.number),
+ "uuid": dept.uuid,
+ "department_id": dept.department_id,
"name": dept.name,
}
)
- op_session.close()
return departments
def make_result_row(self, dept):
return [
- dept["number"],
+ dept["department_id"],
dept["name"],
]
diff --git a/src/wuttapos/terminal/controls/itemlookup.py b/src/wuttapos/terminal/controls/itemlookup.py
index 192bb12..3290c41 100644
--- a/src/wuttapos/terminal/controls/itemlookup.py
+++ b/src/wuttapos/terminal/controls/itemlookup.py
@@ -31,7 +31,7 @@ class WuttaProductLookup(WuttaLookup):
def get_results_columns(self):
return [
- self.app.get_product_key_label(),
+ "Product ID",
"Description",
"Price",
]
@@ -41,7 +41,7 @@ class WuttaProductLookup(WuttaLookup):
def make_result_row(self, product):
return [
- product["product_key"],
+ product["product_id"],
product["full_description"],
product["unit_price_display"],
]
diff --git a/src/wuttapos/terminal/controls/txnitem.py b/src/wuttapos/terminal/controls/txnitem.py
index 93c0d1e..445c872 100644
--- a/src/wuttapos/terminal/controls/txnitem.py
+++ b/src/wuttapos/terminal/controls/txnitem.py
@@ -35,35 +35,28 @@ class WuttaTxnItem(ft.Row):
font_size = 24
- def __init__(self, config, line, page=None, *args, **kwargs):
+ def __init__(self, config, row, page=None, *args, **kwargs):
self.on_reset = kwargs.pop("on_reset", None)
super().__init__(*args, **kwargs)
self.config = config
self.app = config.get_app()
- self.enum = self.app.enum
+ enum = self.app.enum
- self.line = line
+ self.row = row
self.major_style = ft.TextStyle(size=self.font_size, weight=ft.FontWeight.BOLD)
self.minor_style = ft.TextStyle(size=int(self.font_size * 0.8), italic=True)
- # if self.row.row_type in (self.enum.POS_ROW_TYPE_SELL,
- # self.enum.POS_ROW_TYPE_OPEN_RING):
- # self.build_item_sell()
+ if self.row.row_type in (enum.POS_ROW_TYPE_SELL, enum.POS_ROW_TYPE_OPEN_RING):
+ self.build_item_sell()
# elif self.row.row_type in (self.enum.POS_ROW_TYPE_TENDER,
# self.enum.POS_ROW_TYPE_CHANGE_BACK):
# self.build_item_tender()
- if self.line.trans_type in ("I",):
- self.build_item_sell()
-
- elif self.line.trans_type in ("T",):
- self.build_item_tender()
-
def build_item_sell(self):
self.quantity = ft.TextSpan(style=self.minor_style)
@@ -84,7 +77,7 @@ class WuttaTxnItem(ft.Row):
self.controls = [
ft.Text(
spans=[
- ft.TextSpan(f"{self.line.description}", style=self.major_style),
+ ft.TextSpan(f"{self.row.description}", style=self.major_style),
ft.TextSpan("× ", style=self.minor_style),
self.quantity,
ft.TextSpan(" @ ", style=self.minor_style),
@@ -105,13 +98,13 @@ class WuttaTxnItem(ft.Row):
self.controls = [
ft.Text(
spans=[
- ft.TextSpan(f"{self.line.description}", style=self.major_style),
+ ft.TextSpan(f"{self.row.description}", style=self.major_style),
],
),
ft.Text(
spans=[
ft.TextSpan(
- self.app.render_currency(self.line.total),
+ self.app.render_currency(self.row.tender_total),
style=self.major_style,
),
],
@@ -127,9 +120,9 @@ class WuttaTxnItem(ft.Row):
self.on_reset(e=e)
def refresh(self, update=True):
+ enum = self.app.enum
- # if self.row.void:
- if self.line.voided:
+ if self.row.void:
self.major_style.color = None
self.major_style.decoration = ft.TextDecoration.LINE_THROUGH
self.major_style.weight = None
@@ -142,31 +135,32 @@ class WuttaTxnItem(ft.Row):
self.minor_style.color = None
self.minor_style.decoration = None
- # if self.row.row_type in (self.enum.POS_ROW_TYPE_SELL,
- # self.enum.POS_ROW_TYPE_OPEN_RING):
- if self.line.trans_type in ("I",):
- self.quantity.text = self.app.render_quantity(self.line.ItemQtty)
- self.txn_price.text = self.app.render_currency(self.line.unitPrice)
- self.sales_total.text = self.app.render_currency(self.line.total)
- self.fs_flag.text = "FS " if self.line.foodstamp else ""
- self.tax_flag.text = f"T{self.line.tax} " if self.line.tax else ""
+ if self.row.row_type in (enum.POS_ROW_TYPE_SELL, enum.POS_ROW_TYPE_OPEN_RING):
+ self.quantity.text = self.app.render_quantity(self.row.quantity)
+ self.txn_price.text = self.app.render_currency(self.row.txn_price)
+ self.sales_total.text = self.app.render_currency(self.row.sales_total)
+ self.fs_flag.text = "FS " if self.row.foodstamp_eligible else ""
+ self.tax_flag.text = f"T{self.row.tax_code} " if self.row.tax_code else ""
- if self.line.voided:
+ # if self.line.voided:
+ if self.row.void:
self.sales_total_style.color = None
self.sales_total_style.decoration = ft.TextDecoration.LINE_THROUGH
self.sales_total_style.weight = None
else:
- # if (self.row.row_type == self.enum.POS_ROW_TYPE_SELL
- # and self.row.txn_price_adjusted):
- # self.sales_total_style.color = 'orange'
- # elif (self.row.row_type == self.enum.POS_ROW_TYPE_SELL
- # and self.row.cur_price and self.row.cur_price < self.row.reg_price):
- # self.sales_total_style.color = 'green'
- # else:
- # self.sales_total_style.color = None
-
- # TODO
- self.sales_total_style.color = None
+ if (
+ self.row.row_type == enum.POS_ROW_TYPE_SELL
+ and self.row.txn_price_adjusted
+ ):
+ self.sales_total_style.color = "orange"
+ elif (
+ self.row.row_type == enum.POS_ROW_TYPE_SELL
+ and self.row.cur_price
+ and self.row.cur_price < self.row.reg_price
+ ):
+ self.sales_total_style.color = "green"
+ else:
+ self.sales_total_style.color = None
self.sales_total_style.decoration = None
self.sales_total_style.weight = ft.FontWeight.BOLD
diff --git a/src/wuttapos/terminal/controls/txnlookup.py b/src/wuttapos/terminal/controls/txnlookup.py
index f51958f..ee1c3cb 100644
--- a/src/wuttapos/terminal/controls/txnlookup.py
+++ b/src/wuttapos/terminal/controls/txnlookup.py
@@ -47,7 +47,7 @@ class WuttaTransactionLookup(WuttaLookup):
def get_results_columns(self):
return [
- "Date/Time",
+ "Created",
"Terminal",
"Txn ID",
"Cashier",
@@ -56,39 +56,37 @@ class WuttaTransactionLookup(WuttaLookup):
]
def get_results(self, session, entry):
- # model = self.app.model
+ model = self.app.model
- # # TODO: how to deal with 'modes'
- # assert self.mode == 'resume'
- # training = bool(self.mypage.session.get('training'))
- # query = session.query(model.POSBatch)\
- # .filter(model.POSBatch.status_code == model.POSBatch.STATUS_SUSPENDED)\
- # .filter(model.POSBatch.executed == None)\
- # .filter(model.POSBatch.training_mode == training)\
- # .order_by(model.POSBatch.created.desc())
+ # TODO: how to deal with 'modes'
+ assert self.mode == "resume"
+ training = bool(self.mypage.session.get("training"))
+ query = (
+ session.query(model.POSBatch)
+ .filter(model.POSBatch.status_code == model.POSBatch.STATUS_SUSPENDED)
+ .filter(model.POSBatch.executed == None)
+ .filter(model.POSBatch.training_mode == training)
+ .order_by(model.POSBatch.created.desc())
+ )
- # transactions = []
- # for batch in query:
- # # TODO: should use 'suspended' timestamp instead here?
- # # dt = self.app.localtime(batch.created, from_utc=True)
- # dt = batch.created
- # transactions.append({
- # 'uuid': batch.uuid,
- # 'datetime': self.app.render_datetime(dt),
- # 'terminal': batch.terminal_id,
- # 'txnid': batch.id_str,
- # 'cashier': str(batch.cashier or ''),
- # 'customer': str(batch.customer or ''),
- # 'balance': self.app.render_currency(batch.get_balance()),
- # })
- # return transactions
-
- # TODO
- return []
+ transactions = []
+ for batch in query:
+ transactions.append(
+ {
+ "uuid": batch.uuid,
+ "created": self.app.render_datetime(batch.created),
+ "terminal": batch.terminal.terminal_id,
+ "txnid": batch.id_str,
+ "cashier": batch.cashier.name,
+ "customer": batch.customer.name,
+ "balance": self.app.render_currency(batch.get_balance()),
+ }
+ )
+ return transactions
def make_result_row(self, txn):
return [
- txn["datetime"],
+ txn["created"],
txn["terminal"],
txn["txnid"],
txn["cashier"],
diff --git a/src/wuttapos/terminal/util.py b/src/wuttapos/terminal/util.py
index fe4f974..cf0debe 100644
--- a/src/wuttapos/terminal/util.py
+++ b/src/wuttapos/terminal/util.py
@@ -35,3 +35,14 @@ def show_snackbar(page, text, bgcolor="yellow"):
)
page.overlay.append(snack_bar)
snack_bar.open = True
+
+
+def get_pos_batch_handler(config):
+ """
+ Official way of obtaining the POS batch handler.
+
+ Code should use this where possible to make later refactoring
+ easier, should it be needed.
+ """
+ app = config.get_app()
+ return app.get_batch_handler("pos", default="wuttapos.batch.pos:POSBatchHandler")
diff --git a/src/wuttapos/terminal/views/base.py b/src/wuttapos/terminal/views/base.py
index 669a2b2..9a62729 100644
--- a/src/wuttapos/terminal/views/base.py
+++ b/src/wuttapos/terminal/views/base.py
@@ -32,7 +32,7 @@ import flet as ft
from wuttapos.terminal.controls.header import WuttaHeader
from wuttapos.terminal.controls.buttons import make_button
-from wuttapos.terminal.util import show_snackbar
+from wuttapos.terminal.util import show_snackbar, get_pos_batch_handler
class WuttaView(ft.View):
@@ -56,14 +56,14 @@ class WuttaView(ft.View):
return [self.build_header()]
def build_header(self):
- handler = self.get_transaction_handler()
+ handler = self.get_batch_handler()
self.header = WuttaHeader(
self.config, on_reset=self.reset, terminal_id=handler.get_terminal_id()
)
return self.header
- def get_transaction_handler(self):
- return self.app.get_transaction_handler()
+ def get_batch_handler(self):
+ return get_pos_batch_handler(self.config)
def make_button(self, *args, **kwargs):
return make_button(*args, **kwargs)
diff --git a/src/wuttapos/terminal/views/pos.py b/src/wuttapos/terminal/views/pos.py
index 600255e..36f4482 100644
--- a/src/wuttapos/terminal/views/pos.py
+++ b/src/wuttapos/terminal/views/pos.py
@@ -112,37 +112,39 @@ class POSView(WuttaView):
self.show_snackbar(f"CUSTOMER SET: {customer}", bgcolor="green")
- def refresh_totals(self, txn):
+ def refresh_totals(self, batch):
reg = ft.TextStyle(size=22)
bold = ft.TextStyle(size=24, weight=ft.FontWeight.BOLD)
self.subtotals.spans.clear()
- sales_total = txn["sales_total"]
+ sales_total = batch.sales_total or 0
self.subtotals.spans.append(ft.TextSpan("Sales ", style=reg))
total = self.app.render_currency(sales_total)
self.subtotals.spans.append(ft.TextSpan(total, style=bold))
tax_total = 0
- for tax_id, tax in sorted(txn["taxes"].items()):
- if tax["tax_total"]:
- self.subtotals.spans.append(
- ft.TextSpan(f" Tax {tax_id} ", style=reg)
- )
- total = self.app.render_currency(tax["tax_total"])
- self.subtotals.spans.append(ft.TextSpan(total, style=bold))
- tax_total += tax["tax_total"]
+ # for tax_id, tax in sorted(txn["taxes"].items()):
+ # if tax["tax_total"]:
+ # self.subtotals.spans.append(
+ # ft.TextSpan(f" Tax {tax_id} ", style=reg)
+ # )
+ # total = self.app.render_currency(tax["tax_total"])
+ # self.subtotals.spans.append(ft.TextSpan(total, style=bold))
+ # tax_total += tax["tax_total"]
- tender_total = sum(
- [tender["tender_total"] for tender in txn["tenders"].values()]
- )
+ tender_total = 0
+ # tender_total = sum(
+ # [tender["tender_total"] for tender in txn["tenders"].values()]
+ # )
if tender_total:
self.subtotals.spans.append(ft.TextSpan(f" Tend ", style=reg))
total = self.app.render_currency(tender_total)
self.subtotals.spans.append(ft.TextSpan(total, style=bold))
self.fs_balance.spans.clear()
- fs_total = txn["foodstamp"]
+ # fs_total = txn["foodstamp"]
+ fs_total = 0
fs_balance = fs_total + tender_total
if fs_balance:
self.fs_balance.spans.append(ft.TextSpan("FS ", style=reg))
@@ -162,6 +164,8 @@ class POSView(WuttaView):
self.totals_row.bgcolor = "orange"
def attempt_add_product(self, uuid=None, record_badscan=False):
+ model = self.app.model
+ enum = self.app.enum
session = self.app.make_session()
handler = self.get_batch_handler()
user = self.get_current_user(session)
@@ -175,10 +179,9 @@ class POSView(WuttaView):
product = None
item_entry = entry
if uuid:
- product = session.get(self.model.Product, uuid)
+ product = session.get(model.Product, uuid)
assert product
- key = self.app.get_product_key_field()
- item_entry = str(getattr(product, key) or "") or uuid
+ item_entry = product.product_id or uuid
try:
row = handler.process_entry(
@@ -198,12 +201,13 @@ class POSView(WuttaView):
if row:
session.commit()
- if row.row_type == self.enum.POS_ROW_TYPE_BADPRICE:
+ if row.row_type == enum.POS_ROW_TYPE_BADPRICE:
self.show_snackbar(
f"Product has invalid price: {row.item_entry}", bgcolor="yellow"
)
else:
+ session.expunge(row)
self.add_row_item(row, scroll=True)
self.refresh_totals(batch)
self.reset()
@@ -248,10 +252,11 @@ class POSView(WuttaView):
self.page.update()
def customer_lookup(self, value=None, user=None):
+ model = self.app.model
def select(uuid):
session = self.app.make_session()
- customer = session.get(self.model.Customer, uuid)
+ customer = session.get(model.Customer, uuid)
self.set_customer(customer, user=user)
session.commit()
session.close()
@@ -263,46 +268,42 @@ class POSView(WuttaView):
dlg.open = False
self.reset()
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
- self.reset()
+ dlg = ft.AlertDialog(
+ modal=True,
+ title=ft.Text("Customer Lookup"),
+ content=WuttaCustomerLookup(
+ self.config, initial_search=value, on_select=select, on_cancel=cancel
+ ),
+ )
- # dlg = ft.AlertDialog(
- # modal=True,
- # title=ft.Text("Customer Lookup"),
- # content=WuttaCustomerLookup(self.config, initial_search=value,
- # on_select=select, on_cancel=cancel),
- # )
+ # self.page.open(dlg)
- # # self.page.open(dlg)
-
- # self.page.dialog = dlg
- # dlg.open = True
- # self.page.update()
+ self.page.dialog = dlg
+ dlg.open = True
+ self.page.update()
def customer_info(self):
- # clientele = self.app.get_clientele_handler()
- # session = self.app.make_session()
+ model = self.app.model
+ session = self.app.make_session()
+ clientele = self.app.get_clientele_handler()
- # entry = self.main_input.value
- # if entry:
- # different = True
- # customer = clientele.locate_customer_for_entry(session, entry)
- # if not customer:
- # session.close()
- # self.show_snackbar(f"CUSTOMER NOT FOUND: {entry}", bgcolor='yellow')
- # self.page.update()
- # return
+ entry = self.main_input.value
+ if entry:
+ different = True
+ customer = clientele.locate_customer_for_entry(session, entry)
+ if not customer:
+ session.close()
+ self.show_snackbar(f"CUSTOMER NOT FOUND: {entry}", bgcolor="yellow")
+ self.page.update()
+ return
- # else:
- # different = False
- # customer = session.get(self.model.Customer, self.page.session.get('cust_uuid'))
- # assert customer
+ else:
+ different = False
+ customer = session.get(model.Customer, self.page.session.get("cust_uuid"))
+ assert customer
- # info = clientele.get_customer_info_markdown(customer)
- # session.close()
-
- info = "TODO: customer info"
- different = False
+ info = clientele.get_customer_info_markdown(customer)
+ session.close()
def close(e):
dlg.open = False
@@ -523,22 +524,18 @@ class POSView(WuttaView):
self.page.update()
def remove_customer(self, user):
+ session = self.app.make_session()
+ handler = self.get_batch_handler()
+ batch = self.get_current_batch(session)
+ user = session.get(user.__class__, user.uuid)
+ handler.set_customer(batch, None, user=user)
+ session.commit()
+ session.close()
- # session = self.app.make_session()
- # handler = self.get_batch_handler()
- # batch = self.get_current_batch(session)
- # user = session.get(user.__class__, user.uuid)
- # handler.set_customer(batch, None, user=user)
- # session.commit()
- # session.close()
-
- # self.page.session.set('cust_uuid', None)
- # self.page.session.set('cust_display', None)
- # self.informed_refresh()
- # self.show_snackbar("CUSTOMER REMOVED", bgcolor='yellow')
- # self.reset()
-
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
+ self.page.session.set("cust_uuid", None)
+ self.page.session.set("cust_display", None)
+ self.informed_refresh()
+ self.show_snackbar("CUSTOMER REMOVED", bgcolor="yellow")
self.reset()
def replace_customer(self, user):
@@ -745,19 +742,16 @@ class POSView(WuttaView):
self.page.update()
def suspend_transaction(self, user):
- # session = self.app.make_session()
- # batch = self.get_current_batch(session)
- # user = session.get(user.__class__, user.uuid)
- # handler = self.get_batch_handler()
+ session = self.app.make_session()
+ batch = self.get_current_batch(session)
+ user = session.get(user.__class__, user.uuid)
+ handler = self.get_batch_handler()
- # handler.suspend_transaction(batch, user)
+ handler.suspend_transaction(batch, user)
- # session.commit()
- # session.close()
- # self.clear_all()
- # self.reset()
-
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
+ session.commit()
+ session.close()
+ self.clear_all()
self.reset()
def get_current_user(self, session):
@@ -766,6 +760,12 @@ class POSView(WuttaView):
if uuid:
return session.get(model.User, uuid)
+ def refresh_training(self):
+ if self.page.session.get("training"):
+ self.bgcolor = "#E4D97C"
+ else:
+ self.bgcolor = None
+
def get_current_batch(self, session, user=None, create=True):
handler = self.get_batch_handler()
@@ -783,35 +783,11 @@ class POSView(WuttaView):
return batch
- def refresh_training(self):
- if self.page.session.get("training"):
- self.bgcolor = "#E4D97C"
- else:
- self.bgcolor = None
-
- def get_current_transaction(self, session, user=None, create=True):
- handler = self.get_transaction_handler()
-
- if not user:
- user = self.get_current_user(session)
-
- training = bool(self.page.session.get("training"))
- txn, created = handler.get_current_transaction(
- user, training_mode=training, create=create, return_created=True
- )
-
- if created:
- self.page.session.set("txn_display", handler.get_screen_txn_display(txn))
- self.informed_refresh()
-
- return txn
-
def did_mount(self):
session = self.app.make_session()
- txn = self.get_current_transaction(session, create=False)
- if txn:
- self.load_transaction(txn)
+ if batch := self.get_current_batch(session, create=False):
+ self.load_batch(batch)
else:
self.page.session.set("txn_display", None)
self.page.session.set("cust_uuid", None)
@@ -826,21 +802,27 @@ class POSView(WuttaView):
session.close()
self.page.update()
- def load_transaction(self, txn):
+ def load_batch(self, batch):
"""
Load the given data as the current transaction.
"""
- handler = self.get_transaction_handler()
- self.page.session.set("txn_display", handler.get_screen_txn_display(txn))
- self.page.session.set("cust_uuid", txn["customer_id"])
- self.page.session.set("cust_display", handler.get_screen_cust_display(txn=txn))
+ session = self.app.get_session(batch)
+ handler = self.get_batch_handler()
+ self.page.session.set("txn_display", handler.get_screen_txn_display(batch))
+ self.page.session.set(
+ "cust_uuid", batch.customer.uuid if batch.customer else None
+ )
+ self.page.session.set(
+ "cust_display", handler.get_screen_cust_display(batch=batch)
+ )
self.items.controls.clear()
- for line in txn["lines"]:
- self.add_row_item(line)
+ for row in batch.rows:
+ session.expunge(row)
+ self.add_row_item(row)
self.items.scroll_to(offset=-1, duration=100)
- self.refresh_totals(txn)
+ self.refresh_totals(batch)
self.informed_refresh()
def not_supported(self, e=None, feature=None):
@@ -871,6 +853,7 @@ class POSView(WuttaView):
return amount
def adjust_price(self, user):
+ enum = self.app.enum
def cancel(e):
dlg.open = False
@@ -910,12 +893,12 @@ class POSView(WuttaView):
self.refresh_totals(batch)
# update item display
+ session.expunge(row)
self.selected_item.data["row"] = row
self.selected_item.content.row = row
self.selected_item.content.refresh()
self.items.update()
- session.expunge_all()
session.close()
self.clear_item_selection()
self.reset()
@@ -942,7 +925,7 @@ class POSView(WuttaView):
current_price = self.app.render_currency(row.cur_price)
if current_price:
current_price += " [{}]".format(
- self.enum.PRICE_TYPE.get(row.cur_price_type, row.cur_price_type)
+ enum.PRICE_TYPE.get(row.cur_price_type, row.cur_price_type)
)
dlg = ft.AlertDialog(
@@ -1054,29 +1037,26 @@ class POSView(WuttaView):
self.show_snackbar("TODO: Drawer Kick", bgcolor="yellow")
self.page.update()
- def add_row_item(self, line, scroll=False):
+ def add_row_item(self, row, scroll=False):
+ enum = self.app.enum
# TODO: row types ugh
- if line.trans_type not in ("I", "T"):
+ if row.row_type not in (
+ enum.POS_ROW_TYPE_SELL,
+ enum.POS_ROW_TYPE_OPEN_RING,
+ enum.POS_ROW_TYPE_TENDER,
+ enum.POS_ROW_TYPE_CHANGE_BACK,
+ ):
return
- # # TODO: row types ugh
- # if row.row_type not in (self.enum.POS_ROW_TYPE_SELL,
- # self.enum.POS_ROW_TYPE_OPEN_RING,
- # self.enum.POS_ROW_TYPE_TENDER,
- # self.enum.POS_ROW_TYPE_CHANGE_BACK):
- # return
-
self.items.controls.append(
ft.Container(
- content=WuttaTxnItem(self.config, line),
+ content=WuttaTxnItem(self.config, row),
border=ft.border.only(bottom=ft.border.BorderSide(1, "gray")),
padding=ft.padding.only(5, 5, 5, 5),
on_click=self.list_item_click,
- # data={'row': row},
- data={"line": line},
- # key=row.uuid,
- key=line.trans_id,
+ data={"row": row},
+ key=row.uuid,
bgcolor="white",
)
)
@@ -1172,19 +1152,16 @@ class POSView(WuttaView):
self.page.update()
def void_transaction(self, user):
- # session = self.app.make_session()
- # batch = self.get_current_batch(session)
- # user = session.get(user.__class__, user.uuid)
- # handler = self.get_batch_handler()
+ session = self.app.make_session()
+ batch = self.get_current_batch(session)
+ user = session.get(user.__class__, user.uuid)
+ handler = self.get_batch_handler()
- # handler.void_batch(batch, user)
+ handler.void_batch(batch, user)
- # session.commit()
- # session.close()
- # self.clear_all()
- # self.reset()
-
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
+ session.commit()
+ session.close()
+ self.clear_all()
self.reset()
def clear_item_selection(self):
@@ -1236,6 +1213,7 @@ class POSView(WuttaView):
self.page.update()
def cmd_adjust_price_dwim(self, entry=None, **kwargs):
+ enum = self.app.enum
if not len(self.items.controls):
self.show_snackbar("There are no line items", bgcolor="yellow")
@@ -1248,19 +1226,19 @@ class POSView(WuttaView):
self.page.update()
return
- # row = self.selected_item.data['row']
- # if row.void or row.row_type not in (self.enum.POS_ROW_TYPE_SELL,
- # self.enum.POS_ROW_TYPE_OPEN_RING):
- # self.show_snackbar("This item cannot be adjusted", bgcolor='yellow')
- # self.main_input.focus()
- # self.page.update()
- # return
+ row = self.selected_item.data["row"]
+ if row.void or row.row_type not in (
+ enum.POS_ROW_TYPE_SELL,
+ enum.POS_ROW_TYPE_OPEN_RING,
+ ):
+ self.show_snackbar("This item cannot be adjusted", bgcolor="yellow")
+ self.main_input.focus()
+ self.page.update()
+ return
- # self.authorized_action('pos.override_price', self.adjust_price,
- # message="Adjust Price")
-
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
- self.page.update()
+ self.authorized_action(
+ "pos.override_price", self.adjust_price, message="Adjust Price"
+ )
def cmd_context_menu(self, entry=None, **kwargs):
"""
@@ -1304,26 +1282,20 @@ class POSView(WuttaView):
value = self.main_input.value
if value:
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
- self.reset()
- # if not self.attempt_add_product():
- # self.item_lookup(value)
+ if not self.attempt_add_product():
+ self.item_lookup(value)
elif self.selected_item:
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
- self.reset()
- # row = self.selected_item.data['row']
- # if row.product_uuid:
- # if self.attempt_add_product(uuid=row.product_uuid):
- # self.clear_item_selection()
- # self.page.update()
- # else:
- # self.item_lookup()
+ row = self.selected_item.data["row"]
+ if row.product_uuid:
+ if self.attempt_add_product(uuid=row.product_uuid):
+ self.clear_item_selection()
+ self.page.update()
+ else:
+ self.item_lookup()
else:
- # self.item_lookup()
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
- self.reset()
+ self.item_lookup()
def cmd_item_menu_dept(self, entry=None, **kwargs):
"""
@@ -1373,9 +1345,9 @@ class POSView(WuttaView):
self.page.update()
session = self.app.make_session()
- txn = self.get_current_transaction(session, create=False)
+ batch = self.get_current_batch(session, create=False)
session.close()
- if txn:
+ if batch:
self.show_snackbar("TRANSACTION IN PROGRESS")
self.reset()
@@ -1429,10 +1401,10 @@ class POSView(WuttaView):
def cmd_no_sale_dwim(self, entry=None, **kwargs):
session = self.app.make_session()
- txn = self.get_current_transaction(session, create=False)
+ batch = self.get_current_batch(session, create=False)
session.close()
- if txn:
+ if batch:
self.show_snackbar("TRANSACTION IN PROGRESS", bgcolor="yellow")
self.page.update()
return
@@ -1453,27 +1425,27 @@ class POSView(WuttaView):
return
def select(uuid):
- # session = self.app.make_session()
- # user = self.get_current_user(session)
- # batch = self.get_current_batch(session, user=user)
- # handler = self.get_batch_handler()
+ session = self.app.make_session()
+ user = self.get_current_user(session)
+ batch = self.get_current_batch(session, user=user)
+ handler = self.get_batch_handler()
- # quantity = 1
- # if self.set_quantity.data is not None:
- # quantity = self.set_quantity.data
+ quantity = 1
+ if self.set_quantity.data is not None:
+ quantity = self.set_quantity.data
- # row = handler.add_open_ring(batch, uuid, amount, quantity=quantity, user=user)
- # session.commit()
+ row = handler.add_open_ring(
+ batch, uuid, amount, quantity=quantity, user=user
+ )
+ session.commit()
- # self.add_row_item(row, scroll=True)
- # self.refresh_totals(batch)
- # session.close()
-
- # dlg.open = False
- # self.reset()
+ session.refresh(row)
+ session.expunge(row)
+ self.add_row_item(row, scroll=True)
+ self.refresh_totals(batch)
+ session.close()
dlg.open = False
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
self.reset()
def cancel(e):
@@ -1503,9 +1475,8 @@ class POSView(WuttaView):
def cmd_refresh_txn(self, entry=None, **kwargs):
session = self.app.make_session()
- txn = self.get_current_transaction(session, create=False)
- if txn:
- self.load_transaction(txn)
+ if batch := self.get_current_batch(session, create=False):
+ self.load_batch(batch)
else:
self.page.session.set("txn_display", None)
self.page.session.set("cust_uuid", None)
@@ -1523,36 +1494,33 @@ class POSView(WuttaView):
def cmd_resume_txn(self, entry=None, **kwargs):
session = self.app.make_session()
- txn = self.get_current_transaction(session, create=False)
+ batch = self.get_current_batch(session, create=False)
session.close()
# can't resume if txn in progress
- if txn:
+ if batch:
self.show_snackbar("TRANSACTION IN PROGRESS", bgcolor="yellow")
self.reset()
return
def select(uuid):
- # session = self.app.make_session()
- # user = self.get_current_user(session)
- # handler = self.get_batch_handler()
+ model = self.app.model
+ session = self.app.make_session()
+ user = self.get_current_user(session)
+ handler = self.get_batch_handler()
- # # TODO: this would need to work differently if suspended
- # # txns are kept in a central server DB
- # batch = session.get(self.app.model.POSBatch, uuid)
+ # TODO: this would need to work differently if suspended
+ # txns are kept in a central server DB
+ batch = session.get(model.POSBatch, uuid)
- # batch = handler.resume_transaction(batch, user)
- # session.commit()
+ batch = handler.resume_transaction(batch, user)
+ session.commit()
- # session.refresh(batch)
- # self.load_batch(batch)
- # session.close()
-
- # dlg.open = False
- # self.reset()
+ session.refresh(batch)
+ self.load_batch(batch)
+ session.close()
dlg.open = False
- self.show_snackbar("TODO: not implemented", bgcolor="yellow")
self.reset()
def cancel(e):
@@ -1614,11 +1582,11 @@ class POSView(WuttaView):
def cmd_suspend_txn(self, entry=None, **kwargs):
session = self.app.make_session()
- txn = self.get_current_transaction(session, create=False)
+ batch = self.get_current_batch(session, create=False)
session.close()
# nothing to suspend if no txn
- if not txn:
+ if not batch:
self.show_snackbar("NO TRANSACTION", bgcolor="yellow")
self.reset()
return
@@ -1807,13 +1775,13 @@ class POSView(WuttaView):
# self.reset()
def cmd_void_dwim(self, entry=None, **kwargs):
-
+ enum = self.app.enum
session = self.app.make_session()
- txn = self.get_current_transaction(session, create=False)
+ batch = self.get_current_batch(session, create=False)
session.close()
# nothing to void if no txn
- if not txn:
+ if not batch:
self.show_snackbar("NO TRANSACTION", bgcolor="yellow")
self.reset()
return
@@ -1836,8 +1804,8 @@ class POSView(WuttaView):
self.show_snackbar("LINE ALREADY VOID", bgcolor="yellow")
elif row.row_type not in (
- self.enum.POS_ROW_TYPE_SELL,
- self.enum.POS_ROW_TYPE_OPEN_RING,
+ enum.POS_ROW_TYPE_SELL,
+ enum.POS_ROW_TYPE_OPEN_RING,
):
# cannot void line unless of type 'sell'
self.show_snackbar("LINE DOES NOT ALLOW VOID", bgcolor="yellow")