Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script. Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
7.9 KiB
Python
241 lines
7.9 KiB
Python
"""Phase 6.1 CRM validators."""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from shared.exceptions import AppError
|
||
|
||
from app.models.types import CustomFieldType, LeadStatus
|
||
|
||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||
PHONE_RE = re.compile(r"^[\d\s\+\-\(\)]{5,30}$")
|
||
|
||
|
||
def validate_non_empty(value: str | None, *, field: str) -> str:
|
||
if value is None or not str(value).strip():
|
||
raise AppError(
|
||
f"{field} الزامی است",
|
||
status_code=422,
|
||
error_code="validation_error",
|
||
details={"field": field},
|
||
)
|
||
return str(value).strip()
|
||
|
||
|
||
def validate_email(email: str | None, *, required: bool = False) -> str | None:
|
||
if email is None or not email.strip():
|
||
if required:
|
||
raise AppError(
|
||
"ایمیل الزامی است",
|
||
status_code=422,
|
||
error_code="email_required",
|
||
)
|
||
return None
|
||
cleaned = email.strip().lower()
|
||
if not EMAIL_RE.match(cleaned):
|
||
raise AppError(
|
||
"فرمت ایمیل نامعتبر است",
|
||
status_code=422,
|
||
error_code="invalid_email",
|
||
)
|
||
return cleaned
|
||
|
||
|
||
def validate_phone(phone: str | None, *, field: str = "phone") -> str | None:
|
||
if phone is None or not phone.strip():
|
||
return None
|
||
cleaned = phone.strip()
|
||
if not PHONE_RE.match(cleaned):
|
||
raise AppError(
|
||
f"فرمت {field} نامعتبر است",
|
||
status_code=422,
|
||
error_code="invalid_phone",
|
||
details={"field": field},
|
||
)
|
||
return cleaned
|
||
|
||
|
||
def validate_owner(owner_user_id: str | None) -> str:
|
||
return validate_non_empty(owner_user_id, field="owner_user_id")
|
||
|
||
|
||
def validate_lead_status(status: LeadStatus | str) -> LeadStatus:
|
||
if isinstance(status, LeadStatus):
|
||
return status
|
||
try:
|
||
return LeadStatus(status)
|
||
except ValueError as exc:
|
||
raise AppError(
|
||
"وضعیت سرنخ نامعتبر است",
|
||
status_code=422,
|
||
error_code="invalid_lead_status",
|
||
) from exc
|
||
|
||
|
||
def validate_amount(amount: Decimal | None) -> Decimal:
|
||
value = amount if amount is not None else Decimal("0")
|
||
if value < 0:
|
||
raise AppError(
|
||
"مبلغ نمیتواند منفی باشد",
|
||
status_code=422,
|
||
error_code="invalid_amount",
|
||
)
|
||
return value
|
||
|
||
|
||
def validate_currency_code(code: str) -> str:
|
||
cleaned = code.strip().upper()
|
||
if len(cleaned) != 3:
|
||
raise AppError(
|
||
"کد ارز باید ۳ حرفی باشد",
|
||
status_code=422,
|
||
error_code="invalid_currency_code",
|
||
)
|
||
return cleaned
|
||
|
||
|
||
def validate_win_probability(value: int) -> int:
|
||
if value < 0 or value > 100:
|
||
raise AppError(
|
||
"احتمال برد باید بین ۰ و ۱۰۰ باشد",
|
||
status_code=422,
|
||
error_code="invalid_win_probability",
|
||
)
|
||
return value
|
||
|
||
|
||
def validate_address_line(line1: str | None) -> str:
|
||
return validate_non_empty(line1, field="line1")
|
||
|
||
|
||
def validate_tag_name(name: str | None) -> str:
|
||
cleaned = validate_non_empty(name, field="name")
|
||
if len(cleaned) > 100:
|
||
raise AppError(
|
||
"نام تگ بیش از حد طولانی است",
|
||
status_code=422,
|
||
error_code="tag_name_too_long",
|
||
)
|
||
return cleaned
|
||
|
||
|
||
def validate_custom_field_value(
|
||
*,
|
||
field_type: CustomFieldType,
|
||
is_required: bool,
|
||
options: list | None,
|
||
value_text: str | None = None,
|
||
value_number: Decimal | None = None,
|
||
value_boolean: bool | None = None,
|
||
value_date: Any = None,
|
||
value_json: list | dict | None = None,
|
||
) -> None:
|
||
has_value = any(
|
||
v is not None
|
||
for v in (value_text, value_number, value_boolean, value_date, value_json)
|
||
)
|
||
if is_required and not has_value:
|
||
raise AppError(
|
||
"مقدار فیلد سفارشی الزامی است",
|
||
status_code=422,
|
||
error_code="custom_field_required",
|
||
)
|
||
if field_type == CustomFieldType.TEXT and value_text is None and has_value:
|
||
raise AppError(
|
||
"مقدار متنی مورد انتظار است",
|
||
status_code=422,
|
||
error_code="custom_field_type_mismatch",
|
||
)
|
||
if field_type == CustomFieldType.NUMBER and value_number is None and has_value:
|
||
raise AppError(
|
||
"مقدار عددی مورد انتظار است",
|
||
status_code=422,
|
||
error_code="custom_field_type_mismatch",
|
||
)
|
||
if field_type == CustomFieldType.BOOLEAN and value_boolean is None and has_value:
|
||
raise AppError(
|
||
"مقدار بولین مورد انتظار است",
|
||
status_code=422,
|
||
error_code="custom_field_type_mismatch",
|
||
)
|
||
if field_type == CustomFieldType.DATE and value_date is None and has_value:
|
||
raise AppError(
|
||
"مقدار تاریخ مورد انتظار است",
|
||
status_code=422,
|
||
error_code="custom_field_type_mismatch",
|
||
)
|
||
if field_type == CustomFieldType.SELECT and value_text is not None and options:
|
||
if value_text not in options:
|
||
raise AppError(
|
||
"گزینه انتخابی نامعتبر است",
|
||
status_code=422,
|
||
error_code="custom_field_invalid_option",
|
||
)
|
||
if field_type == CustomFieldType.MULTI_SELECT and value_json is not None and options:
|
||
if not isinstance(value_json, list):
|
||
raise AppError(
|
||
"مقدار چندگزینهای باید لیست باشد",
|
||
status_code=422,
|
||
error_code="custom_field_type_mismatch",
|
||
)
|
||
for item in value_json:
|
||
if item not in options:
|
||
raise AppError(
|
||
"گزینه انتخابی نامعتبر است",
|
||
status_code=422,
|
||
error_code="custom_field_invalid_option",
|
||
)
|
||
|
||
|
||
def validate_file_storage_id(file_storage_id: str | None) -> str:
|
||
return validate_non_empty(file_storage_id, field="file_storage_id")
|
||
|
||
|
||
def validate_attachment_file_name(file_name: str | None) -> str:
|
||
return validate_non_empty(file_name, field="file_name")
|
||
|
||
|
||
# Compatibility aliases used by Phase 6.0 opportunity/quote services
|
||
ensure_quote_number = lambda n: validate_non_empty(n, field="quote_number") # noqa: E731
|
||
|
||
|
||
def ensure_opportunity_open(status) -> None:
|
||
from app.models.types import OpportunityStatus
|
||
|
||
if status != OpportunityStatus.OPEN:
|
||
raise AppError(
|
||
"فقط فرصتهای باز قابل این عملیات هستند",
|
||
status_code=409,
|
||
error_code="opportunity_not_open",
|
||
)
|
||
|
||
|
||
def ensure_activity_completable(status) -> None:
|
||
from app.models.types import ActivityStatus
|
||
|
||
if status == ActivityStatus.COMPLETED:
|
||
raise AppError(
|
||
"فعالیت قبلاً تکمیل شده است",
|
||
status_code=409,
|
||
error_code="activity_already_completed",
|
||
)
|
||
if status == ActivityStatus.CANCELLED:
|
||
raise AppError(
|
||
"فعالیت لغوشده قابل تکمیل نیست",
|
||
status_code=409,
|
||
error_code="activity_cancelled",
|
||
)
|
||
|
||
|
||
def ensure_quote_mutable(status) -> None:
|
||
from app.models.types import QuoteStatus
|
||
|
||
if status in {QuoteStatus.ACCEPTED, QuoteStatus.CANCELLED}:
|
||
raise AppError(
|
||
"این پیشفاکتور قابل ویرایش نیست",
|
||
status_code=409,
|
||
error_code="quote_not_mutable",
|
||
)
|