feat(vendor): add contact info inheritance from company
Vendors can now override company contact information for specific branding. Fields are nullable - if null, value is inherited from parent company. Database changes: - Add vendor.contact_email, contact_phone, website, business_address, tax_number - All nullable (null = inherit from company) - Alembic migration: 28d44d503cac Model changes: - Add effective_* properties for resolved values - Add get_contact_info_with_inheritance() helper Schema changes: - VendorCreate: Optional contact fields for override at creation - VendorUpdate: Contact fields + reset_contact_to_company flag - VendorDetailResponse: Resolved values + *_inherited flags API changes: - GET/PUT vendor endpoints return resolved contact info - PUT accepts contact overrides (empty string = reset to inherit) - _build_vendor_detail_response helper for consistent responses Service changes: - admin_service.update_vendor handles reset_contact_to_company flag - Empty strings converted to None for inheritance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
"""add contact fields to vendor
|
||||||
|
|
||||||
|
Revision ID: 28d44d503cac
|
||||||
|
Revises: 9f3a25ea4991
|
||||||
|
Create Date: 2025-12-03 22:26:02.161087
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '28d44d503cac'
|
||||||
|
down_revision: Union[str, None] = '9f3a25ea4991'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add nullable contact fields to vendor table
|
||||||
|
# These allow vendor-specific branding/identity, overriding company defaults
|
||||||
|
op.add_column('vendors', sa.Column('contact_email', sa.String(255), nullable=True))
|
||||||
|
op.add_column('vendors', sa.Column('contact_phone', sa.String(50), nullable=True))
|
||||||
|
op.add_column('vendors', sa.Column('website', sa.String(255), nullable=True))
|
||||||
|
op.add_column('vendors', sa.Column('business_address', sa.Text(), nullable=True))
|
||||||
|
op.add_column('vendors', sa.Column('tax_number', sa.String(100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove contact fields from vendor table
|
||||||
|
op.drop_column('vendors', 'tax_number')
|
||||||
|
op.drop_column('vendors', 'business_address')
|
||||||
|
op.drop_column('vendors', 'website')
|
||||||
|
op.drop_column('vendors', 'contact_phone')
|
||||||
|
op.drop_column('vendors', 'contact_email')
|
||||||
@@ -116,23 +116,14 @@ def get_vendor_statistics_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
|
def _build_vendor_detail_response(vendor) -> VendorDetailResponse:
|
||||||
def get_vendor_details(
|
|
||||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_admin: User = Depends(get_current_admin_api),
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Get detailed vendor information including company and owner details (Admin only).
|
Helper to build VendorDetailResponse with resolved contact info.
|
||||||
|
|
||||||
Accepts either vendor ID (integer) or vendor_code (string).
|
Contact fields are resolved using vendor override or company fallback.
|
||||||
|
Inheritance flags indicate if value comes from company.
|
||||||
Returns vendor info with company contact details and owner info.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
VendorNotFoundException: If vendor not found (404)
|
|
||||||
"""
|
"""
|
||||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
contact_info = vendor.get_contact_info_with_inheritance()
|
||||||
|
|
||||||
return VendorDetailResponse(
|
return VendorDetailResponse(
|
||||||
# Vendor fields
|
# Vendor fields
|
||||||
@@ -151,15 +142,39 @@ def get_vendor_details(
|
|||||||
updated_at=vendor.updated_at,
|
updated_at=vendor.updated_at,
|
||||||
# Company info
|
# Company info
|
||||||
company_name=vendor.company.name,
|
company_name=vendor.company.name,
|
||||||
company_contact_email=vendor.company.contact_email,
|
|
||||||
company_contact_phone=vendor.company.contact_phone,
|
|
||||||
company_website=vendor.company.website,
|
|
||||||
# Owner details (from company)
|
# Owner details (from company)
|
||||||
owner_email=vendor.company.owner.email,
|
owner_email=vendor.company.owner.email,
|
||||||
owner_username=vendor.company.owner.username,
|
owner_username=vendor.company.owner.username,
|
||||||
|
# Resolved contact info with inheritance flags
|
||||||
|
**contact_info,
|
||||||
|
# Original company values for UI reference
|
||||||
|
company_contact_email=vendor.company.contact_email,
|
||||||
|
company_contact_phone=vendor.company.contact_phone,
|
||||||
|
company_website=vendor.company.website,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
|
||||||
|
def get_vendor_details(
|
||||||
|
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: User = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get detailed vendor information including company and owner details (Admin only).
|
||||||
|
|
||||||
|
Accepts either vendor ID (integer) or vendor_code (string).
|
||||||
|
|
||||||
|
Returns vendor info with company contact details, owner info, and
|
||||||
|
resolved contact fields (vendor override or company default).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
VendorNotFoundException: If vendor not found (404)
|
||||||
|
"""
|
||||||
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||||
|
return _build_vendor_detail_response(vendor)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{vendor_identifier}", response_model=VendorDetailResponse)
|
@router.put("/{vendor_identifier}", response_model=VendorDetailResponse)
|
||||||
def update_vendor(
|
def update_vendor(
|
||||||
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
||||||
@@ -176,40 +191,18 @@ def update_vendor(
|
|||||||
- Basic info: name, description, subdomain
|
- Basic info: name, description, subdomain
|
||||||
- Marketplace URLs
|
- Marketplace URLs
|
||||||
- Status: is_active, is_verified
|
- Status: is_active, is_verified
|
||||||
|
- Contact info: contact_email, contact_phone, website, business_address, tax_number
|
||||||
|
(these override company defaults; set to empty to reset to inherit)
|
||||||
|
|
||||||
**Cannot update:**
|
**Cannot update:**
|
||||||
- `vendor_code` (immutable)
|
- `vendor_code` (immutable)
|
||||||
- Business contact info (use company update endpoints)
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
VendorNotFoundException: If vendor not found (404)
|
VendorNotFoundException: If vendor not found (404)
|
||||||
"""
|
"""
|
||||||
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||||
vendor = admin_service.update_vendor(db, vendor.id, vendor_update)
|
vendor = admin_service.update_vendor(db, vendor.id, vendor_update)
|
||||||
|
return _build_vendor_detail_response(vendor)
|
||||||
return VendorDetailResponse(
|
|
||||||
id=vendor.id,
|
|
||||||
vendor_code=vendor.vendor_code,
|
|
||||||
subdomain=vendor.subdomain,
|
|
||||||
name=vendor.name,
|
|
||||||
description=vendor.description,
|
|
||||||
company_id=vendor.company_id,
|
|
||||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
|
||||||
is_active=vendor.is_active,
|
|
||||||
is_verified=vendor.is_verified,
|
|
||||||
created_at=vendor.created_at,
|
|
||||||
updated_at=vendor.updated_at,
|
|
||||||
# Company info
|
|
||||||
company_name=vendor.company.name,
|
|
||||||
company_contact_email=vendor.company.contact_email,
|
|
||||||
company_contact_phone=vendor.company.contact_phone,
|
|
||||||
company_website=vendor.company.website,
|
|
||||||
# Owner details (from company)
|
|
||||||
owner_email=vendor.company.owner.email,
|
|
||||||
owner_username=vendor.company.owner.username,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE: Ownership transfer is now at the Company level.
|
# NOTE: Ownership transfer is now at the Company level.
|
||||||
@@ -243,29 +236,7 @@ def toggle_vendor_verification(
|
|||||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||||
logger.info(f"Vendor verification updated: {message}")
|
logger.info(f"Vendor verification updated: {message}")
|
||||||
|
|
||||||
return VendorDetailResponse(
|
return _build_vendor_detail_response(vendor)
|
||||||
id=vendor.id,
|
|
||||||
vendor_code=vendor.vendor_code,
|
|
||||||
subdomain=vendor.subdomain,
|
|
||||||
name=vendor.name,
|
|
||||||
description=vendor.description,
|
|
||||||
company_id=vendor.company_id,
|
|
||||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
|
||||||
is_active=vendor.is_active,
|
|
||||||
is_verified=vendor.is_verified,
|
|
||||||
created_at=vendor.created_at,
|
|
||||||
updated_at=vendor.updated_at,
|
|
||||||
# Company info
|
|
||||||
company_name=vendor.company.name,
|
|
||||||
company_contact_email=vendor.company.contact_email,
|
|
||||||
company_contact_phone=vendor.company.contact_phone,
|
|
||||||
company_website=vendor.company.website,
|
|
||||||
# Owner details (from company)
|
|
||||||
owner_email=vendor.company.owner.email,
|
|
||||||
owner_username=vendor.company.owner.username,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{vendor_identifier}/status", response_model=VendorDetailResponse)
|
@router.put("/{vendor_identifier}/status", response_model=VendorDetailResponse)
|
||||||
@@ -294,29 +265,7 @@ def toggle_vendor_status(
|
|||||||
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
||||||
logger.info(f"Vendor status updated: {message}")
|
logger.info(f"Vendor status updated: {message}")
|
||||||
|
|
||||||
return VendorDetailResponse(
|
return _build_vendor_detail_response(vendor)
|
||||||
id=vendor.id,
|
|
||||||
vendor_code=vendor.vendor_code,
|
|
||||||
subdomain=vendor.subdomain,
|
|
||||||
name=vendor.name,
|
|
||||||
description=vendor.description,
|
|
||||||
company_id=vendor.company_id,
|
|
||||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
|
||||||
is_active=vendor.is_active,
|
|
||||||
is_verified=vendor.is_verified,
|
|
||||||
created_at=vendor.created_at,
|
|
||||||
updated_at=vendor.updated_at,
|
|
||||||
# Company info
|
|
||||||
company_name=vendor.company.name,
|
|
||||||
company_contact_email=vendor.company.contact_email,
|
|
||||||
company_contact_phone=vendor.company.contact_phone,
|
|
||||||
company_website=vendor.company.website,
|
|
||||||
# Owner details (from company)
|
|
||||||
owner_email=vendor.company.owner.email,
|
|
||||||
owner_username=vendor.company.owner.username,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{vendor_identifier}")
|
@router.delete("/{vendor_identifier}")
|
||||||
|
|||||||
@@ -369,6 +369,21 @@ class AdminService:
|
|||||||
# Get update data
|
# Get update data
|
||||||
update_data = vendor_update.model_dump(exclude_unset=True)
|
update_data = vendor_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle reset_contact_to_company flag
|
||||||
|
if update_data.pop("reset_contact_to_company", False):
|
||||||
|
# Reset all contact fields to None (inherit from company)
|
||||||
|
update_data["contact_email"] = None
|
||||||
|
update_data["contact_phone"] = None
|
||||||
|
update_data["website"] = None
|
||||||
|
update_data["business_address"] = None
|
||||||
|
update_data["tax_number"] = None
|
||||||
|
|
||||||
|
# Convert empty strings to None for contact fields (empty = inherit)
|
||||||
|
contact_fields = ["contact_email", "contact_phone", "website", "business_address", "tax_number"]
|
||||||
|
for field in contact_fields:
|
||||||
|
if field in update_data and update_data[field] == "":
|
||||||
|
update_data[field] = None
|
||||||
|
|
||||||
# Check subdomain uniqueness if changing
|
# Check subdomain uniqueness if changing
|
||||||
if (
|
if (
|
||||||
"subdomain" in update_data
|
"subdomain" in update_data
|
||||||
|
|||||||
301
docs/development/migration/vendor-contact-inheritance.md
Normal file
301
docs/development/migration/vendor-contact-inheritance.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# Vendor Contact Inheritance Migration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Feature:** Add contact information fields to Vendor model with inheritance from Company.
|
||||||
|
|
||||||
|
**Pattern:** Nullable with Fallback - Vendor fields are nullable; if null, inherit from parent company at read time.
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Vendors inherit company contact info by default
|
||||||
|
- Can override specific fields for vendor-specific branding/identity
|
||||||
|
- Can reset to "inherit from company" by setting field to null
|
||||||
|
- Company updates automatically reflect in vendors that haven't overridden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
### New Columns in `vendor` Table
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `contact_email` | VARCHAR(255) | Yes | NULL | Override company contact email |
|
||||||
|
| `contact_phone` | VARCHAR(50) | Yes | NULL | Override company contact phone |
|
||||||
|
| `website` | VARCHAR(255) | Yes | NULL | Override company website |
|
||||||
|
| `business_address` | TEXT | Yes | NULL | Override company business address |
|
||||||
|
| `tax_number` | VARCHAR(100) | Yes | NULL | Override company tax number |
|
||||||
|
|
||||||
|
### Resolution Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
effective_value = vendor.field if vendor.field is not None else vendor.company.field
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### 1. Database Model
|
||||||
|
- `models/database/vendor.py` - Add nullable contact fields
|
||||||
|
|
||||||
|
### 2. Alembic Migration
|
||||||
|
- `alembic/versions/xxx_add_vendor_contact_fields.py` - New migration
|
||||||
|
|
||||||
|
### 3. Pydantic Schemas
|
||||||
|
- `models/schema/vendor.py`:
|
||||||
|
- `VendorUpdate` - Add optional contact fields
|
||||||
|
- `VendorResponse` - Add resolved contact fields
|
||||||
|
- `VendorDetailResponse` - Add contact fields with inheritance indicator
|
||||||
|
|
||||||
|
### 4. Service Layer
|
||||||
|
- `app/services/vendor_service.py` - Add contact resolution helper
|
||||||
|
- `app/services/admin_service.py` - Update create/update to handle contact fields
|
||||||
|
|
||||||
|
### 5. API Endpoints
|
||||||
|
- `app/api/v1/admin/vendors.py` - Update responses to include resolved contact info
|
||||||
|
|
||||||
|
### 6. Frontend
|
||||||
|
- `app/templates/admin/vendor-edit.html` - Add contact fields with inheritance toggle
|
||||||
|
- `static/admin/js/vendor-edit.js` - Handle inheritance UI logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Database Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
# models/database/vendor.py
|
||||||
|
|
||||||
|
class Vendor(Base):
|
||||||
|
# ... existing fields ...
|
||||||
|
|
||||||
|
# Contact fields (nullable = inherit from company)
|
||||||
|
contact_email = Column(String(255), nullable=True)
|
||||||
|
contact_phone = Column(String(50), nullable=True)
|
||||||
|
website = Column(String(255), nullable=True)
|
||||||
|
business_address = Column(Text, nullable=True)
|
||||||
|
tax_number = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Helper properties for resolved values
|
||||||
|
@property
|
||||||
|
def effective_contact_email(self) -> str | None:
|
||||||
|
return self.contact_email if self.contact_email is not None else (
|
||||||
|
self.company.contact_email if self.company else None
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_contact_phone(self) -> str | None:
|
||||||
|
return self.contact_phone if self.contact_phone is not None else (
|
||||||
|
self.company.contact_phone if self.company else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... similar for other fields ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Alembic Migration
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('vendor', sa.Column('contact_email', sa.String(255), nullable=True))
|
||||||
|
op.add_column('vendor', sa.Column('contact_phone', sa.String(50), nullable=True))
|
||||||
|
op.add_column('vendor', sa.Column('website', sa.String(255), nullable=True))
|
||||||
|
op.add_column('vendor', sa.Column('business_address', sa.Text(), nullable=True))
|
||||||
|
op.add_column('vendor', sa.Column('tax_number', sa.String(100), nullable=True))
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('vendor', 'tax_number')
|
||||||
|
op.drop_column('vendor', 'business_address')
|
||||||
|
op.drop_column('vendor', 'website')
|
||||||
|
op.drop_column('vendor', 'contact_phone')
|
||||||
|
op.drop_column('vendor', 'contact_email')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Pydantic Schemas
|
||||||
|
|
||||||
|
```python
|
||||||
|
# models/schema/vendor.py
|
||||||
|
|
||||||
|
class VendorUpdate(BaseModel):
|
||||||
|
# ... existing fields ...
|
||||||
|
|
||||||
|
# Contact fields (None = don't update, empty string could mean "clear/inherit")
|
||||||
|
contact_email: str | None = None
|
||||||
|
contact_phone: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
business_address: str | None = None
|
||||||
|
tax_number: str | None = None
|
||||||
|
|
||||||
|
class VendorContactInfo(BaseModel):
|
||||||
|
"""Resolved contact information with inheritance indicators."""
|
||||||
|
contact_email: str | None
|
||||||
|
contact_phone: str | None
|
||||||
|
website: str | None
|
||||||
|
business_address: str | None
|
||||||
|
tax_number: str | None
|
||||||
|
|
||||||
|
# Inheritance flags
|
||||||
|
contact_email_inherited: bool = False
|
||||||
|
contact_phone_inherited: bool = False
|
||||||
|
website_inherited: bool = False
|
||||||
|
business_address_inherited: bool = False
|
||||||
|
tax_number_inherited: bool = False
|
||||||
|
|
||||||
|
class VendorDetailResponse(BaseModel):
|
||||||
|
# ... existing fields ...
|
||||||
|
|
||||||
|
# Resolved contact info
|
||||||
|
contact_email: str | None
|
||||||
|
contact_phone: str | None
|
||||||
|
website: str | None
|
||||||
|
business_address: str | None
|
||||||
|
tax_number: str | None
|
||||||
|
|
||||||
|
# Inheritance indicators (for UI)
|
||||||
|
contact_email_inherited: bool
|
||||||
|
contact_phone_inherited: bool
|
||||||
|
website_inherited: bool
|
||||||
|
business_address_inherited: bool
|
||||||
|
tax_number_inherited: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Service Layer Helper
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/services/vendor_service.py
|
||||||
|
|
||||||
|
def get_resolved_contact_info(self, vendor: Vendor) -> dict:
|
||||||
|
"""
|
||||||
|
Get resolved contact information with inheritance flags.
|
||||||
|
|
||||||
|
Returns dict with both values and flags indicating if inherited.
|
||||||
|
"""
|
||||||
|
company = vendor.company
|
||||||
|
|
||||||
|
return {
|
||||||
|
"contact_email": vendor.contact_email or (company.contact_email if company else None),
|
||||||
|
"contact_email_inherited": vendor.contact_email is None and company is not None,
|
||||||
|
|
||||||
|
"contact_phone": vendor.contact_phone or (company.contact_phone if company else None),
|
||||||
|
"contact_phone_inherited": vendor.contact_phone is None and company is not None,
|
||||||
|
|
||||||
|
"website": vendor.website or (company.website if company else None),
|
||||||
|
"website_inherited": vendor.website is None and company is not None,
|
||||||
|
|
||||||
|
"business_address": vendor.business_address or (company.business_address if company else None),
|
||||||
|
"business_address_inherited": vendor.business_address is None and company is not None,
|
||||||
|
|
||||||
|
"tax_number": vendor.tax_number or (company.tax_number if company else None),
|
||||||
|
"tax_number_inherited": vendor.tax_number is None and company is not None,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: API Endpoint Updates
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/api/v1/admin/vendors.py
|
||||||
|
|
||||||
|
@router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
|
||||||
|
def get_vendor_details(...):
|
||||||
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
||||||
|
contact_info = vendor_service.get_resolved_contact_info(vendor)
|
||||||
|
|
||||||
|
return VendorDetailResponse(
|
||||||
|
# ... existing fields ...
|
||||||
|
**contact_info, # Includes values and inheritance flags
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Frontend UI
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Vendor edit form with inheritance toggle -->
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="text-gray-700 dark:text-gray-400">
|
||||||
|
Contact Email
|
||||||
|
<span x-show="contactEmailInherited" class="text-xs text-purple-500">(inherited from company)</span>
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="email" x-model="formData.contact_email"
|
||||||
|
:placeholder="companyContactEmail"
|
||||||
|
:class="{ 'bg-gray-100': contactEmailInherited }">
|
||||||
|
<button type="button" @click="resetToCompany('contact_email')"
|
||||||
|
x-show="!contactEmailInherited"
|
||||||
|
class="text-sm text-purple-600">
|
||||||
|
Reset to Company
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Behavior
|
||||||
|
|
||||||
|
### GET /api/v1/admin/vendors/{id}
|
||||||
|
|
||||||
|
Returns resolved contact info with inheritance flags:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"vendor_code": "VENDOR001",
|
||||||
|
"name": "My Vendor",
|
||||||
|
"contact_email": "sales@company.com",
|
||||||
|
"contact_email_inherited": true,
|
||||||
|
"contact_phone": "+352 123 456",
|
||||||
|
"contact_phone_inherited": false,
|
||||||
|
"website": "https://company.com",
|
||||||
|
"website_inherited": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT /api/v1/admin/vendors/{id}
|
||||||
|
|
||||||
|
To override a field:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contact_email": "vendor-specific@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To reset to inherit from company (set to null):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contact_email": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
1. **Create vendor** - Verify contact fields are null (inheriting)
|
||||||
|
2. **Read vendor** - Verify resolved values come from company
|
||||||
|
3. **Update vendor contact** - Verify override works
|
||||||
|
4. **Reset to inherit** - Verify setting null restores inheritance
|
||||||
|
5. **Update company** - Verify change reflects in inheriting vendors
|
||||||
|
6. **Update company** - Verify change does NOT affect overridden vendors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues occur:
|
||||||
|
1. Run downgrade migration: `alembic downgrade -1`
|
||||||
|
2. Revert code changes
|
||||||
|
3. Re-deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
- [ ] Database model updated
|
||||||
|
- [ ] Alembic migration created and applied
|
||||||
|
- [ ] Pydantic schemas updated
|
||||||
|
- [ ] Service layer helper added
|
||||||
|
- [ ] API endpoints updated
|
||||||
|
- [ ] Frontend forms updated
|
||||||
|
- [ ] Tests written and passing
|
||||||
|
- [ ] Documentation updated
|
||||||
@@ -63,6 +63,17 @@ class Vendor(Base, TimestampMixin):
|
|||||||
Boolean, default=False
|
Boolean, default=False
|
||||||
) # Boolean to indicate if the vendor brand is verified
|
) # Boolean to indicate if the vendor brand is verified
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Contact Information (nullable = inherit from company)
|
||||||
|
# ========================================================================
|
||||||
|
# These fields allow vendor-specific branding/identity.
|
||||||
|
# If null, the value is inherited from the parent company.
|
||||||
|
contact_email = Column(String(255), nullable=True) # Override company contact email
|
||||||
|
contact_phone = Column(String(50), nullable=True) # Override company contact phone
|
||||||
|
website = Column(String(255), nullable=True) # Override company website
|
||||||
|
business_address = Column(Text, nullable=True) # Override company business address
|
||||||
|
tax_number = Column(String(100), nullable=True) # Override company tax number
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Relationships
|
# Relationships
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -202,6 +213,66 @@ class Vendor(Base, TimestampMixin):
|
|||||||
domains.append(domain.domain) # Add other active custom domains
|
domains.append(domain.domain) # Add other active custom domains
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Contact Resolution Helper Properties
|
||||||
|
# ========================================================================
|
||||||
|
# These properties return the effective value (vendor override or company fallback)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_contact_email(self) -> str | None:
|
||||||
|
"""Get contact email (vendor override or company fallback)."""
|
||||||
|
if self.contact_email is not None:
|
||||||
|
return self.contact_email
|
||||||
|
return self.company.contact_email if self.company else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_contact_phone(self) -> str | None:
|
||||||
|
"""Get contact phone (vendor override or company fallback)."""
|
||||||
|
if self.contact_phone is not None:
|
||||||
|
return self.contact_phone
|
||||||
|
return self.company.contact_phone if self.company else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_website(self) -> str | None:
|
||||||
|
"""Get website (vendor override or company fallback)."""
|
||||||
|
if self.website is not None:
|
||||||
|
return self.website
|
||||||
|
return self.company.website if self.company else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_business_address(self) -> str | None:
|
||||||
|
"""Get business address (vendor override or company fallback)."""
|
||||||
|
if self.business_address is not None:
|
||||||
|
return self.business_address
|
||||||
|
return self.company.business_address if self.company else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_tax_number(self) -> str | None:
|
||||||
|
"""Get tax number (vendor override or company fallback)."""
|
||||||
|
if self.tax_number is not None:
|
||||||
|
return self.tax_number
|
||||||
|
return self.company.tax_number if self.company else None
|
||||||
|
|
||||||
|
def get_contact_info_with_inheritance(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get all contact info with inheritance flags.
|
||||||
|
|
||||||
|
Returns dict with resolved values and flags indicating if inherited from company.
|
||||||
|
"""
|
||||||
|
company = self.company
|
||||||
|
return {
|
||||||
|
"contact_email": self.effective_contact_email,
|
||||||
|
"contact_email_inherited": self.contact_email is None and company is not None,
|
||||||
|
"contact_phone": self.effective_contact_phone,
|
||||||
|
"contact_phone_inherited": self.contact_phone is None and company is not None,
|
||||||
|
"website": self.effective_website,
|
||||||
|
"website_inherited": self.website is None and company is not None,
|
||||||
|
"business_address": self.effective_business_address,
|
||||||
|
"business_address_inherited": self.business_address is None and company is not None,
|
||||||
|
"tax_number": self.effective_tax_number,
|
||||||
|
"tax_number_inherited": self.tax_number is None and company is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class VendorUserType(str, enum.Enum):
|
class VendorUserType(str, enum.Enum):
|
||||||
"""Types of vendor users."""
|
"""Types of vendor users."""
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ class VendorCreate(BaseModel):
|
|||||||
"""
|
"""
|
||||||
Schema for creating a new vendor (storefront/brand) under an existing company.
|
Schema for creating a new vendor (storefront/brand) under an existing company.
|
||||||
|
|
||||||
Business contact info is inherited from the parent company.
|
Contact info is inherited from the parent company by default.
|
||||||
|
Optionally, provide contact fields to override from the start.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Parent company
|
# Parent company
|
||||||
@@ -51,6 +52,13 @@ class VendorCreate(BaseModel):
|
|||||||
letzshop_csv_url_en: str | None = Field(None, description="English CSV URL")
|
letzshop_csv_url_en: str | None = Field(None, description="English CSV URL")
|
||||||
letzshop_csv_url_de: str | None = Field(None, description="German CSV URL")
|
letzshop_csv_url_de: str | None = Field(None, description="German CSV URL")
|
||||||
|
|
||||||
|
# Contact Info (optional - if not provided, inherited from company)
|
||||||
|
contact_email: str | None = Field(None, description="Override company contact email")
|
||||||
|
contact_phone: str | None = Field(None, description="Override company contact phone")
|
||||||
|
website: str | None = Field(None, description="Override company website")
|
||||||
|
business_address: str | None = Field(None, description="Override company business address")
|
||||||
|
tax_number: str | None = Field(None, description="Override company tax number")
|
||||||
|
|
||||||
@field_validator("subdomain")
|
@field_validator("subdomain")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_subdomain(cls, v):
|
def validate_subdomain(cls, v):
|
||||||
@@ -72,8 +80,8 @@ class VendorUpdate(BaseModel):
|
|||||||
"""
|
"""
|
||||||
Schema for updating vendor information (Admin only).
|
Schema for updating vendor information (Admin only).
|
||||||
|
|
||||||
Note: Business contact info (contact_email, etc.) is at the Company level.
|
Contact fields can be overridden at the vendor level.
|
||||||
Use company update endpoints to modify those fields.
|
Set to null/empty to reset to company default (inherit).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Basic Information
|
# Basic Information
|
||||||
@@ -90,6 +98,18 @@ class VendorUpdate(BaseModel):
|
|||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
is_verified: bool | None = None
|
is_verified: bool | None = None
|
||||||
|
|
||||||
|
# Contact Info (set value to override, set to empty string to reset to inherit)
|
||||||
|
contact_email: str | None = Field(None, description="Override company contact email")
|
||||||
|
contact_phone: str | None = Field(None, description="Override company contact phone")
|
||||||
|
website: str | None = Field(None, description="Override company website")
|
||||||
|
business_address: str | None = Field(None, description="Override company business address")
|
||||||
|
tax_number: str | None = Field(None, description="Override company tax number")
|
||||||
|
|
||||||
|
# Special flag to reset contact fields to inherit from company
|
||||||
|
reset_contact_to_company: bool | None = Field(
|
||||||
|
None, description="If true, reset all contact fields to inherit from company"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("subdomain")
|
@field_validator("subdomain")
|
||||||
@classmethod
|
@classmethod
|
||||||
def subdomain_lowercase(cls, v):
|
def subdomain_lowercase(cls, v):
|
||||||
@@ -135,16 +155,14 @@ class VendorResponse(BaseModel):
|
|||||||
|
|
||||||
class VendorDetailResponse(VendorResponse):
|
class VendorDetailResponse(VendorResponse):
|
||||||
"""
|
"""
|
||||||
Extended vendor response including company information.
|
Extended vendor response including company information and resolved contact info.
|
||||||
|
|
||||||
Includes company details like contact info and owner information.
|
Contact fields show the effective value (vendor override or company default)
|
||||||
|
with flags indicating if the value is inherited from the parent company.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Company info
|
# Company info
|
||||||
company_name: str = Field(..., description="Name of the parent company")
|
company_name: str = Field(..., description="Name of the parent company")
|
||||||
company_contact_email: str = Field(..., description="Company business contact email")
|
|
||||||
company_contact_phone: str | None = Field(None, description="Company phone number")
|
|
||||||
company_website: str | None = Field(None, description="Company website URL")
|
|
||||||
|
|
||||||
# Owner info (at company level)
|
# Owner info (at company level)
|
||||||
owner_email: str = Field(
|
owner_email: str = Field(
|
||||||
@@ -152,6 +170,25 @@ class VendorDetailResponse(VendorResponse):
|
|||||||
)
|
)
|
||||||
owner_username: str = Field(..., description="Username of the company owner")
|
owner_username: str = Field(..., description="Username of the company owner")
|
||||||
|
|
||||||
|
# Resolved contact info (vendor override or company default)
|
||||||
|
contact_email: str | None = Field(None, description="Effective contact email")
|
||||||
|
contact_phone: str | None = Field(None, description="Effective contact phone")
|
||||||
|
website: str | None = Field(None, description="Effective website")
|
||||||
|
business_address: str | None = Field(None, description="Effective business address")
|
||||||
|
tax_number: str | None = Field(None, description="Effective tax number")
|
||||||
|
|
||||||
|
# Inheritance flags (True = value is inherited from company, not overridden)
|
||||||
|
contact_email_inherited: bool = Field(False, description="True if contact_email is from company")
|
||||||
|
contact_phone_inherited: bool = Field(False, description="True if contact_phone is from company")
|
||||||
|
website_inherited: bool = Field(False, description="True if website is from company")
|
||||||
|
business_address_inherited: bool = Field(False, description="True if business_address is from company")
|
||||||
|
tax_number_inherited: bool = Field(False, description="True if tax_number is from company")
|
||||||
|
|
||||||
|
# Original company values (for reference in UI)
|
||||||
|
company_contact_email: str | None = Field(None, description="Company's contact email")
|
||||||
|
company_contact_phone: str | None = Field(None, description="Company's phone number")
|
||||||
|
company_website: str | None = Field(None, description="Company's website URL")
|
||||||
|
|
||||||
|
|
||||||
class VendorCreateResponse(VendorDetailResponse):
|
class VendorCreateResponse(VendorDetailResponse):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user