From 548210d64585ca4e43f04988a611872919d2e429 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 2 Jan 2026 20:43:42 -0600 Subject: [PATCH] yowza, whole bunch of changes this adds most of the core schema needed to ring sales at the POS. and the POS now supports several common functions via the button menu. but i have yet to tackle many aspects / features, a lot remains to do --- src/wuttapos/app.py | 62 +- src/wuttapos/batch/__init__.py | 0 src/wuttapos/batch/pos.py | 747 ++++++++++++++++++ src/wuttapos/clientele.py | 284 +++++++ src/wuttapos/config.py | 1 + .../versions/3f548013be91_add_stores.py | 80 ++ .../versions/653b7d27c709_add_terminals.py | 84 ++ .../versions/7067ef686eb0_add_taxes.py | 80 ++ .../versions/8ce8b14af66d_add_employees.py | 98 +++ .../versions/b026e4f5c5bc_add_tenders.py | 92 +++ .../versions/c2b1f0983c97_add_pos_batches.py | 135 ++++ src/wuttapos/db/model/__init__.py | 7 + src/wuttapos/db/model/batch/__init__.py | 0 src/wuttapos/db/model/batch/pos.py | 412 ++++++++++ src/wuttapos/db/model/employees.py | 67 ++ src/wuttapos/db/model/stores.py | 52 ++ src/wuttapos/db/model/taxes.py | 51 ++ src/wuttapos/db/model/tenders.py | 92 +++ src/wuttapos/db/model/terminals.py | 43 + src/wuttapos/employment.py | 57 ++ src/wuttapos/enum.py | 68 ++ src/wuttapos/handler.py | 100 --- src/wuttapos/people.py | 49 ++ src/wuttapos/products.py | 518 ++++++++++++ src/wuttapos/server/forms/schema.py | 88 +++ src/wuttapos/server/menus.py | 66 +- src/wuttapos/server/views/__init__.py | 6 + src/wuttapos/server/views/batch/__init__.py | 0 src/wuttapos/server/views/batch/pos.py | 161 ++++ src/wuttapos/server/views/employees.py | 58 ++ src/wuttapos/server/views/stores.py | 52 ++ src/wuttapos/server/views/taxes.py | 52 ++ src/wuttapos/server/views/tenders.py | 76 ++ src/wuttapos/server/views/terminals.py | 50 ++ src/wuttapos/terminal/app.py | 12 +- src/wuttapos/terminal/controls/custlookup.py | 4 +- src/wuttapos/terminal/controls/deptlookup.py | 22 +- src/wuttapos/terminal/controls/itemlookup.py | 4 +- src/wuttapos/terminal/controls/txnitem.py | 68 +- src/wuttapos/terminal/controls/txnlookup.py | 56 +- src/wuttapos/terminal/util.py | 11 + src/wuttapos/terminal/views/base.py | 8 +- src/wuttapos/terminal/views/pos.py | 396 +++++----- 43 files changed, 3944 insertions(+), 425 deletions(-) create mode 100644 src/wuttapos/batch/__init__.py create mode 100644 src/wuttapos/batch/pos.py create mode 100644 src/wuttapos/clientele.py create mode 100644 src/wuttapos/db/alembic/versions/3f548013be91_add_stores.py create mode 100644 src/wuttapos/db/alembic/versions/653b7d27c709_add_terminals.py create mode 100644 src/wuttapos/db/alembic/versions/7067ef686eb0_add_taxes.py create mode 100644 src/wuttapos/db/alembic/versions/8ce8b14af66d_add_employees.py create mode 100644 src/wuttapos/db/alembic/versions/b026e4f5c5bc_add_tenders.py create mode 100644 src/wuttapos/db/alembic/versions/c2b1f0983c97_add_pos_batches.py create mode 100644 src/wuttapos/db/model/batch/__init__.py create mode 100644 src/wuttapos/db/model/batch/pos.py create mode 100644 src/wuttapos/db/model/employees.py create mode 100644 src/wuttapos/db/model/stores.py create mode 100644 src/wuttapos/db/model/taxes.py create mode 100644 src/wuttapos/db/model/tenders.py create mode 100644 src/wuttapos/db/model/terminals.py create mode 100644 src/wuttapos/employment.py create mode 100644 src/wuttapos/enum.py delete mode 100644 src/wuttapos/handler.py create mode 100644 src/wuttapos/people.py create mode 100644 src/wuttapos/products.py create mode 100644 src/wuttapos/server/views/batch/__init__.py create mode 100644 src/wuttapos/server/views/batch/pos.py create mode 100644 src/wuttapos/server/views/employees.py create mode 100644 src/wuttapos/server/views/stores.py create mode 100644 src/wuttapos/server/views/taxes.py create mode 100644 src/wuttapos/server/views/tenders.py create mode 100644 src/wuttapos/server/views/terminals.py 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")