From 29593f4c61d6f5711aa3cad905d6688f50149df7 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 19 Apr 2026 21:36:49 +0200 Subject: [PATCH] feat(loyalty): multi-select categories on transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from single category_id to category_ids JSON array on transactions. Sellers can now select multiple categories (e.g., Men + Accessories) when entering stamp/points transactions. - Migration loyalty_009: drop category_id FK, add category_ids JSON - Schemas: category_id → category_ids (list[int] | None) - Services: stamp_service + points_service accept category_ids - Terminal UI: pills are now multi-select (toggle on/off) - Transaction response: category_names (list[str]) resolved from IDs - Recent transactions table: new Category column showing comma- separated names Co-Authored-By: Claude Opus 4.6 (1M context) --- ...loyalty_009_category_id_to_category_ids.py | 43 +++++++++++++++++++ .../loyalty/models/loyalty_transaction.py | 8 ++-- app/modules/loyalty/routes/api/store.py | 4 +- app/modules/loyalty/schemas/card.py | 4 +- app/modules/loyalty/schemas/points.py | 6 +-- app/modules/loyalty/schemas/stamp.py | 6 +-- app/modules/loyalty/services/card_service.py | 18 +++++--- .../loyalty/services/points_service.py | 6 +-- app/modules/loyalty/services/stamp_service.py | 4 +- .../static/store/js/loyalty-terminal.js | 6 +-- .../templates/loyalty/store/terminal.html | 8 ++-- 11 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 app/modules/loyalty/migrations/versions/loyalty_009_category_id_to_category_ids.py diff --git a/app/modules/loyalty/migrations/versions/loyalty_009_category_id_to_category_ids.py b/app/modules/loyalty/migrations/versions/loyalty_009_category_id_to_category_ids.py new file mode 100644 index 00000000..3066857c --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_009_category_id_to_category_ids.py @@ -0,0 +1,43 @@ +"""loyalty 009 - replace category_id FK with category_ids JSON + +Switches from single-category to multi-category support on transactions. +Not live yet so no data migration needed. + +Revision ID: loyalty_009 +Revises: loyalty_008 +Create Date: 2026-04-19 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "loyalty_009" +down_revision = "loyalty_008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("loyalty_transactions", "category_id") + op.add_column( + "loyalty_transactions", + sa.Column( + "category_ids", + sa.JSON(), + nullable=True, + comment="List of category IDs selected for this transaction", + ), + ) + + +def downgrade() -> None: + op.drop_column("loyalty_transactions", "category_ids") + op.add_column( + "loyalty_transactions", + sa.Column( + "category_id", + sa.Integer(), + sa.ForeignKey("store_transaction_categories.id", ondelete="SET NULL"), + nullable=True, + ), + ) diff --git a/app/modules/loyalty/models/loyalty_transaction.py b/app/modules/loyalty/models/loyalty_transaction.py index 8f0b61a3..6473769d 100644 --- a/app/modules/loyalty/models/loyalty_transaction.py +++ b/app/modules/loyalty/models/loyalty_transaction.py @@ -25,6 +25,7 @@ from sqlalchemy import ( String, Text, ) +from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base @@ -104,11 +105,10 @@ class LoyaltyTransaction(Base, TimestampMixin): index=True, comment="Staff PIN used for this operation", ) - category_id = Column( - Integer, - ForeignKey("store_transaction_categories.id", ondelete="SET NULL"), + category_ids = Column( + JSON, nullable=True, - comment="Product category (e.g., Men, Women, Accessories)", + comment="List of category IDs selected for this transaction", ) # Related transaction (for voids/returns) diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 2b7bc248..f11c8fd9 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -762,7 +762,7 @@ def add_stamp( qr_code=data.qr_code, card_number=data.card_number, staff_pin=data.staff_pin, - category_id=data.category_id, + category_ids=data.category_ids, ip_address=ip, user_agent=user_agent, notes=data.notes, @@ -853,7 +853,7 @@ def earn_points( purchase_amount_cents=data.purchase_amount_cents, order_reference=data.order_reference, staff_pin=data.staff_pin, - category_id=data.category_id, + category_ids=data.category_ids, ip_address=ip, user_agent=user_agent, notes=data.notes, diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py index be1249b4..30490f0b 100644 --- a/app/modules/loyalty/schemas/card.py +++ b/app/modules/loyalty/schemas/card.py @@ -188,8 +188,8 @@ class TransactionResponse(BaseModel): order_reference: str | None = None reward_id: str | None = None reward_description: str | None = None - category_id: int | None = None - category_name: str | None = None + category_ids: list[int] | None = None + category_names: list[str] | None = None notes: str | None = None # Customer diff --git a/app/modules/loyalty/schemas/points.py b/app/modules/loyalty/schemas/points.py index ded3ebd8..c871cf0e 100644 --- a/app/modules/loyalty/schemas/points.py +++ b/app/modules/loyalty/schemas/points.py @@ -47,10 +47,10 @@ class PointsEarnRequest(BaseModel): description="Staff PIN for verification", ) - # Category (what was sold) - category_id: int | None = Field( + # Categories (what was sold — multi-select) + category_ids: list[int] | None = Field( None, - description="Transaction category ID (e.g., Men, Women, Accessories)", + description="Transaction category IDs", ) # Optional metadata diff --git a/app/modules/loyalty/schemas/stamp.py b/app/modules/loyalty/schemas/stamp.py index 755fe198..459c7e54 100644 --- a/app/modules/loyalty/schemas/stamp.py +++ b/app/modules/loyalty/schemas/stamp.py @@ -37,10 +37,10 @@ class StampRequest(BaseModel): description="Staff PIN for verification", ) - # Category (what was sold) - category_id: int | None = Field( + # Categories (what was sold — multi-select) + category_ids: list[int] | None = Field( None, - description="Transaction category ID (e.g., Men, Women, Accessories)", + description="Transaction category IDs", ) # Optional metadata diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index 7c0c78b4..b600ca9a 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -836,8 +836,8 @@ class CardService: "transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None, "notes": tx.notes, "store_name": None, - "category_id": tx.category_id, - "category_name": None, + "category_ids": tx.category_ids, + "category_names": None, } if tx.store_id: @@ -845,15 +845,19 @@ class CardService: if store_obj: tx_data["store_name"] = store_obj.name - if tx.category_id: + if tx.category_ids and isinstance(tx.category_ids, list): from app.modules.loyalty.services.category_service import ( category_service, ) - cat_name = category_service.validate_category_for_store( - db, tx.category_id, tx.store_id or 0 - ) - tx_data["category_name"] = cat_name + names = [] + for cid in tx.category_ids: + name = category_service.validate_category_for_store( + db, cid, tx.store_id or 0 + ) + if name: + names.append(name) + tx_data["category_names"] = names if names else None tx_responses.append(tx_data) diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py index 756d0c5a..82382a08 100644 --- a/app/modules/loyalty/services/points_service.py +++ b/app/modules/loyalty/services/points_service.py @@ -49,7 +49,7 @@ class PointsService: purchase_amount_cents: int, order_reference: str | None = None, staff_pin: str | None = None, - category_id: int | None = None, + category_ids: list[int] | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, @@ -104,7 +104,7 @@ class PointsService: raise OrderReferenceRequiredException() # Category is mandatory when the store has categories configured - if not category_id: + if not category_ids: from app.modules.loyalty.services.category_service import category_service store_categories = category_service.list_categories( @@ -196,7 +196,7 @@ class PointsService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, - category_id=category_id, + category_ids=category_ids, transaction_type=TransactionType.POINTS_EARNED.value, points_delta=points_earned, stamps_balance_after=card.stamp_count, diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py index 8bd53319..fe183155 100644 --- a/app/modules/loyalty/services/stamp_service.py +++ b/app/modules/loyalty/services/stamp_service.py @@ -46,7 +46,7 @@ class StampService: qr_code: str | None = None, card_number: str | None = None, staff_pin: str | None = None, - category_id: int | None = None, + category_ids: list[int] | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, @@ -144,7 +144,7 @@ class StampService: card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, - category_id=category_id, + category_ids=category_ids, transaction_type=TransactionType.STAMP_EARNED.value, stamps_delta=1, stamps_balance_after=card.stamp_count, diff --git a/app/modules/loyalty/static/store/js/loyalty-terminal.js b/app/modules/loyalty/static/store/js/loyalty-terminal.js index 8138e9c2..64adf977 100644 --- a/app/modules/loyalty/static/store/js/loyalty-terminal.js +++ b/app/modules/loyalty/static/store/js/loyalty-terminal.js @@ -31,7 +31,7 @@ function storeLoyaltyTerminal() { // Transaction inputs earnAmount: null, selectedReward: '', - selectedCategory: null, + selectedCategories: [], categories: [], // PIN entry @@ -300,7 +300,7 @@ function storeLoyaltyTerminal() { await apiClient.post('/store/loyalty/stamp', { card_id: this.selectedCard.id, staff_pin: this.pinDigits, - category_id: this.selectedCategory || undefined, + category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined, }); Utils.showToast(I18n.t('loyalty.store.terminal.stamp_added'), 'success'); @@ -326,7 +326,7 @@ function storeLoyaltyTerminal() { card_id: this.selectedCard.id, purchase_amount_cents: Math.round(this.earnAmount * 100), staff_pin: this.pinDigits, - category_id: this.selectedCategory || undefined, + category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined, }); const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1)); diff --git a/app/modules/loyalty/templates/loyalty/store/terminal.html b/app/modules/loyalty/templates/loyalty/store/terminal.html index d27ea834..4f3f1e2a 100644 --- a/app/modules/loyalty/templates/loyalty/store/terminal.html +++ b/app/modules/loyalty/templates/loyalty/store/terminal.html @@ -286,13 +286,14 @@ {{ _('loyalty.store.terminal.col_customer') }} {{ _('loyalty.store.terminal.col_type') }} {{ _('loyalty.store.terminal.col_points') }} + {{ _('loyalty.store.terminal.select_category') }} {{ _('loyalty.store.terminal.col_notes') }} @@ -331,9 +333,9 @@