feat(client,setting): complete Phase 2 with partitioned client_follow_logs

- apps/client (11 models): Client, ClientContact, ClientRequirement,
  ClientSchoolPreference, ClientFollowLog (partitioned),
  ClientFollowLogAttachment, ClientViewing, ClientPropertyMatch,
  ClientStatusLog, ClientFavoriteFolder, ClientFolderItem
- apps/client/0002 RunSQL: PARTITION BY RANGE(created_at) for
  client_follow_logs + monthly partitions + default; triggers
  update_client_last_follow + update_client_viewing_progress;
  partial unique index on client_no WHERE deleted_at IS NULL
- apps/setting (4 models): LookupGroup, LookupItem, TenantSetting,
  FieldRequirementRule (tenant schema only per spec)

manage.py check green; all 9 Phase 2 apps complete.
This commit is contained in:
Sisyphus
2026-04-29 17:33:58 +08:00
parent 5b55dda367
commit ed40de4050
12 changed files with 1266 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
from .contacts import ClientContact, ClientRequirement, ClientSchoolPreference
from .core import Client
from .folders import ClientFavoriteFolder, ClientFolderItem
from .follow import ClientFollowLog, ClientFollowLogAttachment
from .viewing_match import (
ClientPropertyMatch,
ClientStatusLog,
ClientViewing,
)
__all__ = [
"Client",
"ClientContact",
"ClientRequirement",
"ClientSchoolPreference",
"ClientFollowLog",
"ClientFollowLogAttachment",
"ClientViewing",
"ClientPropertyMatch",
"ClientStatusLog",
"ClientFavoriteFolder",
"ClientFolderItem",
]

View File

@@ -0,0 +1,143 @@
from django.contrib.postgres.fields import ArrayField
from django.db import models
from core.enums import (
ClientBuildingAgeRange,
ClientContactGender,
ClientDecoration,
ClientFloorPreference,
ClientOrientation,
ClientRequirementType,
)
from core.models.base import UUIDPrimaryKeyModel
class ClientContact(UUIDPrimaryKeyModel):
client = models.ForeignKey(
"fonrey_client.Client", on_delete=models.CASCADE, related_name="contacts"
)
sort_order = models.SmallIntegerField(default=0)
name = models.CharField(max_length=50)
gender = models.CharField(
max_length=10, choices=ClientContactGender.choices, default=ClientContactGender.MALE
)
phone_enc = models.BinaryField()
phone_hash = models.CharField(max_length=64)
phone_country_code = models.CharField(max_length=10, default="+86")
phone_is_invalid = models.BooleanField(default=False)
phone2_enc = models.BinaryField(null=True, blank=True)
phone2_hash = models.CharField(max_length=64, blank=True, default="")
wechat = models.CharField(max_length=100, blank=True, default="")
qq = models.CharField(max_length=20, blank=True, default="")
remarks = models.CharField(max_length=200, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_client_contacts",
)
class Meta:
db_table = "client_contacts"
indexes = [
models.Index(fields=["phone_hash"], name="idx_cc_phone_hash"),
models.Index(fields=["phone2_hash"], name="idx_cc_phone2_hash"),
models.Index(fields=["client"], name="idx_cc_client"),
]
class ClientRequirement(UUIDPrimaryKeyModel):
client = models.ForeignKey(
"fonrey_client.Client", on_delete=models.CASCADE, related_name="requirements"
)
requirement_type = models.CharField(
max_length=20, choices=ClientRequirementType.choices
)
is_primary = models.BooleanField(default=True)
budget_min = models.DecimalField(
max_digits=12, decimal_places=2, null=True, blank=True
)
budget_max = models.DecimalField(
max_digits=12, decimal_places=2, null=True, blank=True
)
area_min = models.DecimalField(
max_digits=8, decimal_places=2, null=True, blank=True
)
area_max = models.DecimalField(
max_digits=8, decimal_places=2, null=True, blank=True
)
bedroom_counts = ArrayField(
models.SmallIntegerField(), blank=True, default=list
)
floor_preferences = ArrayField(
models.CharField(max_length=20, choices=ClientFloorPreference.choices),
blank=True,
default=list,
)
orientations = ArrayField(
models.CharField(max_length=10, choices=ClientOrientation.choices),
blank=True,
default=list,
)
decorations = ArrayField(
models.CharField(max_length=10, choices=ClientDecoration.choices),
blank=True,
default=list,
)
building_age_ranges = ArrayField(
models.CharField(max_length=20, choices=ClientBuildingAgeRange.choices),
blank=True,
default=list,
)
intent_district_ids = ArrayField(
models.UUIDField(), blank=True, default=list
)
intent_business_area_ids = ArrayField(
models.UUIDField(), blank=True, default=list
)
intent_complex_names = models.TextField(blank=True, default="")
transportation = models.CharField(max_length=50, blank=True, default="")
intent_school_names = models.TextField(blank=True, default="")
school_enrollment_date = models.DateField(null=True, blank=True)
traffic_preference = models.TextField(blank=True, default="")
requirement_notes = models.CharField(max_length=200, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "client_requirements"
indexes = [
models.Index(fields=["client"], name="idx_creq_client"),
models.Index(fields=["requirement_type", "client"], name="idx_creq_type"),
models.Index(fields=["budget_min", "budget_max"], name="idx_creq_budget"),
models.Index(fields=["area_min", "area_max"], name="idx_creq_area"),
]
class ClientSchoolPreference(UUIDPrimaryKeyModel):
requirement = models.ForeignKey(
ClientRequirement,
on_delete=models.CASCADE,
related_name="school_preferences",
)
school_id = models.UUIDField(null=True, blank=True)
school_name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "client_school_preferences"
indexes = [
models.Index(fields=["requirement"], name="idx_csp_requirement"),
]

147
apps/client/models/core.py Normal file
View File

@@ -0,0 +1,147 @@
from django.contrib.postgres.fields import ArrayField
from django.db import models
from core.enums import (
ClientActivityLevel,
ClientBuyingPurpose,
ClientGrade,
ClientIdType,
ClientInvalidReason,
ClientPaymentMethod,
ClientPropertiesOwned,
ClientPropertyUsage,
ClientStatus,
ClientTransactedPropertyType,
ClientTransactedType,
ClientTransferToPublicType,
ClientType,
)
from core.models.base import AuditedModel
class Client(AuditedModel):
client_no = models.CharField(max_length=30, unique=True)
client_type = models.CharField(
max_length=20, choices=ClientType.choices, default=ClientType.PRIVATE
)
status = models.CharField(
max_length=20, choices=ClientStatus.choices, default=ClientStatus.BUYING
)
grade = models.CharField(
max_length=5, choices=ClientGrade.choices, default=ClientGrade.C
)
property_usage = models.CharField(
max_length=30,
choices=ClientPropertyUsage.choices,
default=ClientPropertyUsage.RESIDENTIAL,
)
buying_purpose = ArrayField(
models.CharField(max_length=20, choices=ClientBuyingPurpose.choices),
blank=True,
default=list,
)
payment_method = models.CharField(
max_length=30, choices=ClientPaymentMethod.choices, blank=True, default=""
)
properties_owned = models.CharField(
max_length=20, choices=ClientPropertiesOwned.choices, blank=True, default=""
)
has_loan_record = models.BooleanField(null=True, blank=True)
id_type = models.CharField(
max_length=20, choices=ClientIdType.choices, blank=True, default=""
)
id_number_enc = models.BinaryField(null=True, blank=True)
source = models.CharField(max_length=50, blank=True, default="")
remarks = models.TextField(blank=True, default="")
is_starred = models.BooleanField(default=False)
is_pinned = models.BooleanField(default=False)
is_big_value = models.BooleanField(default=False)
is_protected = models.BooleanField(default=False)
prefers_new_house = models.BooleanField(null=True, blank=True)
transfer_to_public_type = models.CharField(
max_length=20,
choices=ClientTransferToPublicType.choices,
blank=True,
default="",
)
transferred_public_at = models.DateTimeField(null=True, blank=True)
invalid_reason = models.CharField(
max_length=30, choices=ClientInvalidReason.choices, blank=True, default=""
)
invalidated_at = models.DateTimeField(null=True, blank=True)
transacted_at = models.DateField(null=True, blank=True)
transacted_property = models.ForeignKey(
"fonrey_property.Property",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="transacted_clients",
)
transacted_price = models.DecimalField(
max_digits=12, decimal_places=2, null=True, blank=True
)
transacted_type = models.CharField(
max_length=20, choices=ClientTransactedType.choices, blank=True, default=""
)
transacted_property_type = models.CharField(
max_length=20,
choices=ClientTransactedPropertyType.choices,
blank=True,
default="",
)
first_recorder = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="first_recorded_clients",
)
owner = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="owned_clients",
)
org_unit = models.ForeignKey(
"org.OrgUnit",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="clients",
)
activity_level = models.CharField(
max_length=20, choices=ClientActivityLevel.choices, blank=True, default=""
)
last_active_at = models.DateTimeField(null=True, blank=True)
last_follow_at = models.DateTimeField(null=True, blank=True)
commission_date = models.DateField(null=True, blank=True)
entrust_count = models.SmallIntegerField(default=1)
version = models.IntegerField(default=1)
class Meta:
db_table = "clients"
indexes = [
models.Index(fields=["client_type", "status"], name="idx_clients_type_stat"),
models.Index(fields=["owner"], name="idx_clients_owner"),
models.Index(fields=["org_unit"], name="idx_clients_org_unit"),
models.Index(
fields=["activity_level", "-last_active_at"],
name="idx_clients_activity",
),
models.Index(fields=["grade"], name="idx_clients_grade"),
models.Index(
fields=["-transferred_public_at"], name="idx_clients_transferred"
),
models.Index(fields=["-last_follow_at"], name="idx_clients_last_follow"),
]

View File

@@ -0,0 +1,48 @@
from django.db import models
from core.models.base import UUIDPrimaryKeyModel
class ClientFavoriteFolder(UUIDPrimaryKeyModel):
staff = models.ForeignKey(
"org.Staff", on_delete=models.CASCADE, related_name="favorite_folders"
)
name = models.CharField(max_length=10)
is_default = models.BooleanField(default=False)
sort_order = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "client_favorite_folders"
indexes = [
models.Index(fields=["staff"], name="idx_cff_staff"),
]
constraints = [
models.UniqueConstraint(
fields=["staff"],
condition=models.Q(is_default=True, deleted_at__isnull=True),
name="uq_cff_default_per_staff",
),
]
class ClientFolderItem(models.Model):
folder = models.ForeignKey(
ClientFavoriteFolder, on_delete=models.CASCADE, related_name="items"
)
client = models.ForeignKey(
"fonrey_client.Client", on_delete=models.CASCADE, related_name="folder_items"
)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "client_folder_items"
constraints = [
models.UniqueConstraint(
fields=["folder", "client"], name="uq_cfi_folder_client"
),
]
indexes = [
models.Index(fields=["client"], name="idx_cfi_client"),
]

View File

@@ -0,0 +1,55 @@
from django.db import models
from core.enums import ClientFollowLogType
from core.models.base import UUIDPrimaryKeyModel
class ClientFollowLog(models.Model):
"""Partitioned table (PARTITION BY RANGE created_at).
Managed via RunSQL; Django ORM treats parent as unmanaged.
"""
id = models.UUIDField(primary_key=True)
created_at = models.DateTimeField()
client = models.ForeignKey(
"fonrey_client.Client",
on_delete=models.CASCADE,
related_name="follow_logs",
)
log_type = models.CharField(max_length=30, choices=ClientFollowLogType.choices)
purpose = models.CharField(max_length=50, blank=True, default="")
content = models.TextField(blank=True, default="")
log_tag = models.CharField(max_length=50, blank=True, default="")
change_detail = models.JSONField(null=True, blank=True)
is_public = models.BooleanField(default=True)
is_deletable = models.BooleanField(default=True)
operator = models.ForeignKey(
"org.Staff", null=True, blank=True, on_delete=models.SET_NULL
)
operator_snapshot = models.JSONField(null=True, blank=True)
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "client_follow_logs"
managed = False
unique_together = (("id", "created_at"),)
class ClientFollowLogAttachment(UUIDPrimaryKeyModel):
follow_log_id = models.UUIDField() # cross-partitioned FK; not enforced via Django FK
file_key = models.TextField()
file_name = models.CharField(max_length=255)
file_size = models.IntegerField()
file_type = models.CharField(max_length=10, blank=True, default="")
has_location = models.BooleanField(default=False)
sort_order = models.SmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "client_follow_log_attachments"
indexes = [models.Index(fields=["follow_log_id"], name="idx_cfla_log")]

View File

@@ -0,0 +1,147 @@
from django.contrib.postgres.fields import ArrayField
from django.db import models
from core.enums import (
ClientPropertyMatchGroup,
ClientPropertyMatchSource,
ClientPropertyMatchStatus,
ClientStatusLogChangeType,
ClientViewingIntent,
ClientViewingType,
)
from core.models.base import UUIDPrimaryKeyModel
class ClientViewing(UUIDPrimaryKeyModel):
client = models.ForeignKey(
"fonrey_client.Client", on_delete=models.RESTRICT, related_name="viewings"
)
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.RESTRICT,
related_name="client_viewings",
)
viewing_type = models.CharField(
max_length=20, choices=ClientViewingType.choices, default=ClientViewingType.VIEWING
)
agent = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="led_viewings",
)
companion_ids = ArrayField(models.UUIDField(), blank=True, default=list)
cooperator_ids = ArrayField(models.UUIDField(), blank=True, default=list)
scheduled_at = models.DateTimeField(null=True, blank=True)
viewing_start_at = models.DateTimeField(null=True, blank=True)
viewing_end_at = models.DateTimeField(null=True, blank=True)
situation = models.TextField(blank=True, default="")
client_intent = models.CharField(
max_length=20, choices=ClientViewingIntent.choices, blank=True, default=""
)
viewing_progress = models.SmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
deleted_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_client_viewings",
)
class Meta:
db_table = "client_viewings"
indexes = [
models.Index(
fields=["client", "-viewing_start_at"], name="idx_cv_client_time"
),
models.Index(fields=["property"], name="idx_cv_property"),
models.Index(fields=["agent"], name="idx_cv_agent"),
]
class ClientPropertyMatch(UUIDPrimaryKeyModel):
client = models.ForeignKey(
"fonrey_client.Client", on_delete=models.CASCADE, related_name="property_matches"
)
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.CASCADE,
related_name="client_matches",
)
match_source = models.CharField(
max_length=20,
choices=ClientPropertyMatchSource.choices,
default=ClientPropertyMatchSource.RECORDED,
)
match_group = models.CharField(
max_length=30, choices=ClientPropertyMatchGroup.choices, blank=True, default=""
)
match_score = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True
)
match_reasons = models.JSONField(null=True, blank=True)
status = models.CharField(
max_length=20,
choices=ClientPropertyMatchStatus.choices,
default=ClientPropertyMatchStatus.SUGGESTED,
)
shared_at = models.DateTimeField(null=True, blank=True)
feedback = models.CharField(max_length=50, blank=True, default="")
calculated_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_matches",
)
class Meta:
db_table = "client_property_matches"
constraints = [
models.UniqueConstraint(
fields=["client", "property"], name="uq_client_match_pair"
),
]
indexes = [
models.Index(
fields=["client", "match_source", "match_group"],
name="idx_cpm_client_grp",
),
models.Index(fields=["client", "status"], name="idx_cpm_status"),
]
class ClientStatusLog(models.Model):
"""Audit log; record-level immutable (no deleted_at)."""
id = models.UUIDField(primary_key=True)
client = models.ForeignKey(
"fonrey_client.Client", on_delete=models.RESTRICT, related_name="status_logs"
)
change_type = models.CharField(
max_length=30, choices=ClientStatusLogChangeType.choices
)
old_value = models.JSONField(null=True, blank=True)
new_value = models.JSONField(null=True, blank=True)
reason = models.TextField(blank=True, default="")
operator = models.ForeignKey(
"org.Staff", on_delete=models.RESTRICT, related_name="client_status_changes"
)
operated_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "client_status_logs"
indexes = [
models.Index(fields=["client", "-operated_at"], name="idx_csl_client"),
models.Index(fields=["change_type", "-operated_at"], name="idx_csl_type"),
]