feat: integer cents money handling, order page fixes, and vendor filter persistence

Money Handling Architecture:
- Store all monetary values as integer cents (€105.91 = 10591)
- Add app/utils/money.py with Money class and conversion helpers
- Add static/shared/js/money.js for frontend formatting
- Update all database models to use _cents columns (Product, Order, etc.)
- Update CSV processor to convert prices to cents on import
- Add Alembic migration for Float to Integer conversion
- Create .architecture-rules/money.yaml with 7 validation rules
- Add docs/architecture/money-handling.md documentation

Order Details Page Fixes:
- Fix customer name showing 'undefined undefined' - use flat field names
- Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse
- Fix shipping address using wrong nested object structure
- Enrich order detail API response with vendor info

Vendor Filter Persistence Fixes:
- Fix orders.js: restoreSavedVendor now sets selectedVendor and filters
- Fix orders.js: init() only loads orders if no saved vendor to restore
- Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor()
- Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown
- Align vendor selector placeholder text between pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 20:33:48 +01:00
parent 7f0d32c18d
commit a19c84ea4e
56 changed files with 6155 additions and 447 deletions

View File

@@ -0,0 +1,31 @@
"""add_order_tracking_fields
Revision ID: 55b92e155566
Revises: d2e3f4a5b6c7
Create Date: 2025-12-20 18:07:51.144136
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '55b92e155566'
down_revision: Union[str, None] = 'd2e3f4a5b6c7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new tracking fields to orders table
op.add_column('orders', sa.Column('tracking_url', sa.String(length=500), nullable=True))
op.add_column('orders', sa.Column('shipment_number', sa.String(length=100), nullable=True))
op.add_column('orders', sa.Column('shipping_carrier', sa.String(length=50), nullable=True))
def downgrade() -> None:
op.drop_column('orders', 'shipping_carrier')
op.drop_column('orders', 'shipment_number')
op.drop_column('orders', 'tracking_url')

View File

@@ -0,0 +1,35 @@
"""add_letzshop_credentials_carrier_fields
Revision ID: c00d2985701f
Revises: 55b92e155566
Create Date: 2025-12-20 18:49:53.432904
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c00d2985701f'
down_revision: Union[str, None] = '55b92e155566'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add carrier settings and test mode to vendor_letzshop_credentials
op.add_column('vendor_letzshop_credentials', sa.Column('test_mode_enabled', sa.Boolean(), nullable=True, server_default='0'))
op.add_column('vendor_letzshop_credentials', sa.Column('default_carrier', sa.String(length=50), nullable=True))
op.add_column('vendor_letzshop_credentials', sa.Column('carrier_greco_label_url', sa.String(length=500), nullable=True, server_default='https://dispatchweb.fr/Tracky/Home/'))
op.add_column('vendor_letzshop_credentials', sa.Column('carrier_colissimo_label_url', sa.String(length=500), nullable=True))
op.add_column('vendor_letzshop_credentials', sa.Column('carrier_xpresslogistics_label_url', sa.String(length=500), nullable=True))
def downgrade() -> None:
op.drop_column('vendor_letzshop_credentials', 'carrier_xpresslogistics_label_url')
op.drop_column('vendor_letzshop_credentials', 'carrier_colissimo_label_url')
op.drop_column('vendor_letzshop_credentials', 'carrier_greco_label_url')
op.drop_column('vendor_letzshop_credentials', 'default_carrier')
op.drop_column('vendor_letzshop_credentials', 'test_mode_enabled')

View File

@@ -0,0 +1,223 @@
"""convert_prices_to_integer_cents
Revision ID: e1f2a3b4c5d6
Revises: c00d2985701f
Create Date: 2025-12-20 21:30:00.000000
Converts all price/amount columns from Float to Integer cents.
This follows e-commerce best practices (Stripe, PayPal, Shopify) for
precise monetary calculations.
Example: €105.91 is stored as 10591 (integer cents)
Affected tables:
- products: price, sale_price, supplier_cost, margin_percent
- orders: subtotal, tax_amount, shipping_amount, discount_amount, total_amount
- order_items: unit_price, total_price
- cart_items: price_at_add
- marketplace_products: price_numeric, sale_price_numeric
See docs/architecture/money-handling.md for full documentation.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e1f2a3b4c5d6'
down_revision: Union[str, None] = 'c00d2985701f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite requires batch mode for column alterations
# Strategy: Add new _cents columns, migrate data, drop old columns
# === PRODUCTS TABLE ===
with op.batch_alter_table('products', schema=None) as batch_op:
# Add new cents columns
batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('supplier_cost_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('margin_percent_x100', sa.Integer(), nullable=True))
# Migrate data for products
op.execute('UPDATE products SET price_cents = ROUND(COALESCE(price, 0) * 100)')
op.execute('UPDATE products SET sale_price_cents = ROUND(sale_price * 100) WHERE sale_price IS NOT NULL')
op.execute('UPDATE products SET supplier_cost_cents = ROUND(supplier_cost * 100) WHERE supplier_cost IS NOT NULL')
op.execute('UPDATE products SET margin_percent_x100 = ROUND(margin_percent * 100) WHERE margin_percent IS NOT NULL')
# Drop old columns
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_column('price')
batch_op.drop_column('sale_price')
batch_op.drop_column('supplier_cost')
batch_op.drop_column('margin_percent')
# === ORDERS TABLE ===
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.add_column(sa.Column('subtotal_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('tax_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('shipping_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('discount_amount_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_amount_cents', sa.Integer(), nullable=True))
# Migrate data for orders
op.execute('UPDATE orders SET subtotal_cents = ROUND(COALESCE(subtotal, 0) * 100)')
op.execute('UPDATE orders SET tax_amount_cents = ROUND(COALESCE(tax_amount, 0) * 100)')
op.execute('UPDATE orders SET shipping_amount_cents = ROUND(COALESCE(shipping_amount, 0) * 100)')
op.execute('UPDATE orders SET discount_amount_cents = ROUND(COALESCE(discount_amount, 0) * 100)')
op.execute('UPDATE orders SET total_amount_cents = ROUND(COALESCE(total_amount, 0) * 100)')
# Make total_amount_cents NOT NULL after migration
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_column('subtotal')
batch_op.drop_column('tax_amount')
batch_op.drop_column('shipping_amount')
batch_op.drop_column('discount_amount')
batch_op.drop_column('total_amount')
# Alter total_amount_cents to be NOT NULL
batch_op.alter_column('total_amount_cents',
existing_type=sa.Integer(),
nullable=False)
# === ORDER_ITEMS TABLE ===
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('unit_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('total_price_cents', sa.Integer(), nullable=True))
# Migrate data for order_items
op.execute('UPDATE order_items SET unit_price_cents = ROUND(COALESCE(unit_price, 0) * 100)')
op.execute('UPDATE order_items SET total_price_cents = ROUND(COALESCE(total_price, 0) * 100)')
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.drop_column('unit_price')
batch_op.drop_column('total_price')
batch_op.alter_column('unit_price_cents',
existing_type=sa.Integer(),
nullable=False)
batch_op.alter_column('total_price_cents',
existing_type=sa.Integer(),
nullable=False)
# === CART_ITEMS TABLE ===
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_at_add_cents', sa.Integer(), nullable=True))
# Migrate data for cart_items
op.execute('UPDATE cart_items SET price_at_add_cents = ROUND(COALESCE(price_at_add, 0) * 100)')
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.drop_column('price_at_add')
batch_op.alter_column('price_at_add_cents',
existing_type=sa.Integer(),
nullable=False)
# === MARKETPLACE_PRODUCTS TABLE ===
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('weight_grams', sa.Integer(), nullable=True))
# Migrate data for marketplace_products
op.execute('UPDATE marketplace_products SET price_cents = ROUND(price_numeric * 100) WHERE price_numeric IS NOT NULL')
op.execute('UPDATE marketplace_products SET sale_price_cents = ROUND(sale_price_numeric * 100) WHERE sale_price_numeric IS NOT NULL')
op.execute('UPDATE marketplace_products SET weight_grams = ROUND(weight * 1000) WHERE weight IS NOT NULL')
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.drop_column('price_numeric')
batch_op.drop_column('sale_price_numeric')
batch_op.drop_column('weight')
def downgrade() -> None:
# === MARKETPLACE_PRODUCTS TABLE ===
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_numeric', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('sale_price_numeric', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('weight', sa.Float(), nullable=True))
op.execute('UPDATE marketplace_products SET price_numeric = price_cents / 100.0 WHERE price_cents IS NOT NULL')
op.execute('UPDATE marketplace_products SET sale_price_numeric = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL')
op.execute('UPDATE marketplace_products SET weight = weight_grams / 1000.0 WHERE weight_grams IS NOT NULL')
with op.batch_alter_table('marketplace_products', schema=None) as batch_op:
batch_op.drop_column('price_cents')
batch_op.drop_column('sale_price_cents')
batch_op.drop_column('weight_grams')
# === CART_ITEMS TABLE ===
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('price_at_add', sa.Float(), nullable=True))
op.execute('UPDATE cart_items SET price_at_add = price_at_add_cents / 100.0')
with op.batch_alter_table('cart_items', schema=None) as batch_op:
batch_op.drop_column('price_at_add_cents')
batch_op.alter_column('price_at_add',
existing_type=sa.Float(),
nullable=False)
# === ORDER_ITEMS TABLE ===
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('unit_price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_price', sa.Float(), nullable=True))
op.execute('UPDATE order_items SET unit_price = unit_price_cents / 100.0')
op.execute('UPDATE order_items SET total_price = total_price_cents / 100.0')
with op.batch_alter_table('order_items', schema=None) as batch_op:
batch_op.drop_column('unit_price_cents')
batch_op.drop_column('total_price_cents')
batch_op.alter_column('unit_price',
existing_type=sa.Float(),
nullable=False)
batch_op.alter_column('total_price',
existing_type=sa.Float(),
nullable=False)
# === ORDERS TABLE ===
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.add_column(sa.Column('subtotal', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('tax_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('shipping_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('discount_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_amount', sa.Float(), nullable=True))
op.execute('UPDATE orders SET subtotal = subtotal_cents / 100.0')
op.execute('UPDATE orders SET tax_amount = tax_amount_cents / 100.0')
op.execute('UPDATE orders SET shipping_amount = shipping_amount_cents / 100.0')
op.execute('UPDATE orders SET discount_amount = discount_amount_cents / 100.0')
op.execute('UPDATE orders SET total_amount = total_amount_cents / 100.0')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_column('subtotal_cents')
batch_op.drop_column('tax_amount_cents')
batch_op.drop_column('shipping_amount_cents')
batch_op.drop_column('discount_amount_cents')
batch_op.drop_column('total_amount_cents')
batch_op.alter_column('total_amount',
existing_type=sa.Float(),
nullable=False)
# === PRODUCTS TABLE ===
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.add_column(sa.Column('price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('sale_price', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('supplier_cost', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('margin_percent', sa.Float(), nullable=True))
op.execute('UPDATE products SET price = price_cents / 100.0 WHERE price_cents IS NOT NULL')
op.execute('UPDATE products SET sale_price = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL')
op.execute('UPDATE products SET supplier_cost = supplier_cost_cents / 100.0 WHERE supplier_cost_cents IS NOT NULL')
op.execute('UPDATE products SET margin_percent = margin_percent_x100 / 100.0 WHERE margin_percent_x100 IS NOT NULL')
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_column('price_cents')
batch_op.drop_column('sale_price_cents')
batch_op.drop_column('supplier_cost_cents')
batch_op.drop_column('margin_percent_x100')