Files
orion/docs/development/migration/vendor-contact-inheritance.md
Samir Boulahtit 846f92e7e4 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>
2025-12-03 22:30:31 +01:00

8.8 KiB

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

# 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

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

# 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

# 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

# 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

<!-- 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:

{
  "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:

{
  "contact_email": "vendor-specific@example.com"
}

To reset to inherit from company (set to null):

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