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