From 5b55dda367b66174ece7e37a8d241b553576e2a9 Mon Sep 17 00:00:00 2001 From: ishenwei Date: Wed, 29 Apr 2026 17:27:15 +0800 Subject: [PATCH] feat(property): add 23-table property module with partitioned follow_logs and property_photos --- apps/property/migrations/0001_initial.py | 658 ++++++++++++++++++ .../0002_partitions_and_triggers.py | 136 ++++ apps/property/models/__init__.py | 57 ++ apps/property/models/core.py | 338 +++++++++ apps/property/models/follow_keys.py | 130 ++++ apps/property/models/listings.py | 194 ++++++ apps/property/models/media.py | 166 +++++ core/enums.py | 28 + 8 files changed, 1707 insertions(+) create mode 100644 apps/property/migrations/0001_initial.py create mode 100644 apps/property/migrations/0002_partitions_and_triggers.py create mode 100644 apps/property/models/core.py create mode 100644 apps/property/models/follow_keys.py create mode 100644 apps/property/models/listings.py create mode 100644 apps/property/models/media.py diff --git a/apps/property/migrations/0001_initial.py b/apps/property/migrations/0001_initial.py new file mode 100644 index 0000000..2cf393d --- /dev/null +++ b/apps/property/migrations/0001_initial.py @@ -0,0 +1,658 @@ +# Generated by Django 4.2.16 on 2026-04-29 09:26 + +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('fonrey_complex', '0002_pg_trgm_and_search_vector'), + ('org', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='FollowLog', + fields=[ + ('id', models.UUIDField(primary_key=True, serialize=False)), + ('created_at', models.DateTimeField()), + ('log_type', models.CharField(choices=[('written', '手写跟进'), ('modified', '修改跟进'), ('sensitive_op', '敏感操作'), ('sensitive_view', '敏感查看'), ('other', '其他'), ('system', '系统')], max_length=30)), + ('purpose', models.CharField(blank=True, default='', max_length=50)), + ('content', models.TextField(blank=True, default='')), + ('ai_tag', models.CharField(blank=True, choices=[('ai_for_sale', 'AI判断可售'), ('ai_not_for_sale', 'AI判断不可售')], default='', max_length=20)), + ('change_detail', models.JSONField(blank=True, null=True)), + ('log_tag', models.CharField(blank=True, default='', max_length=50)), + ('is_public', models.BooleanField(default=True)), + ('operator_snapshot', models.JSONField(blank=True, null=True)), + ('is_deletable', models.BooleanField(default=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'db_table': 'follow_logs', + 'managed': False, + }, + ), + migrations.CreateModel( + name='PropertyPhoto', + fields=[ + ('id', models.UUIDField(primary_key=True, serialize=False)), + ('created_at', models.DateTimeField()), + ('category', models.CharField(choices=[('cover', '封面'), ('entrance', '入户'), ('living_room', '客厅'), ('dining_room', '餐厅'), ('bedroom', '卧室'), ('bathroom', '卫生间'), ('kitchen', '厨房'), ('balcony', '阳台'), ('study', '书房'), ('indoor_other', '室内其他'), ('outdoor', '室外'), ('panorama', '全景')], max_length=20)), + ('file_key', models.TextField()), + ('thumbnail_key', models.TextField(blank=True, default='')), + ('file_name', models.CharField(blank=True, default='', max_length=255)), + ('file_size', models.IntegerField(blank=True, null=True)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('is_cover', models.BooleanField(default=False)), + ('sort_order', models.SmallIntegerField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'property_photos', + 'managed': False, + }, + ), + migrations.CreateModel( + name='Commission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('commission_type', models.CharField(max_length=50)), + ('period_start', models.DateField()), + ('period_end', models.DateField(blank=True, null=True)), + ('is_open_ended', models.BooleanField(default=False)), + ('agent_snapshot', models.JSONField(blank=True, null=True)), + ('signing_method', models.CharField(blank=True, default='', max_length=50)), + ('owner_type', models.CharField(choices=[('owner', '产权人本人'), ('authorized_third', '授权第三方')], default='owner', max_length=20)), + ('owner_name', models.CharField(blank=True, default='', max_length=50)), + ('owner_id_type', models.CharField(blank=True, default='', max_length=20)), + ('owner_id_number', models.CharField(blank=True, default='', max_length=50)), + ('owner_id_number_enc', models.BinaryField(blank=True, null=True)), + ('remarks', models.TextField(blank=True, default='')), + ('status', models.CharField(choices=[('active', '有效'), ('expired', '过期'), ('cancelled', '取消')], default='active', max_length=20)), + ('agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='commissions_as_agent', to='org.staff')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_commissions', to='org.staff')), + ], + options={ + 'db_table': 'commissions', + }, + ), + migrations.CreateModel( + name='Property', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('property_type', models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('shop', '商铺'), ('office', '写字楼'), ('other', '其他')], max_length=30)), + ('status', models.CharField(choices=[('for_sale', '出售'), ('for_rent', '出租'), ('for_sale_rent', '租售'), ('suspended', '暂缓'), ('sold_elsewhere', '他售'), ('rented_elsewhere', '他租'), ('sold', '成交'), ('unlisted', '未挂牌')], default='for_sale', max_length=20)), + ('attribute', models.CharField(choices=[('public', '公盘'), ('private', '私盘'), ('special', '特盘'), ('sealed', '封盘')], default='public', max_length=10)), + ('private_reason', models.TextField(blank=True, default='')), + ('block_no', models.CharField(blank=True, default='', max_length=30)), + ('unit_no', models.CharField(blank=True, default='', max_length=30)), + ('room_no', models.CharField(blank=True, default='', max_length=30)), + ('floor', models.SmallIntegerField()), + ('total_floors', models.SmallIntegerField()), + ('bedroom_count', models.SmallIntegerField(default=0)), + ('living_room_count', models.SmallIntegerField(default=0)), + ('bathroom_count', models.SmallIntegerField(default=0)), + ('kitchen_count', models.SmallIntegerField(default=0)), + ('balcony_count', models.SmallIntegerField(default=0)), + ('area', models.DecimalField(decimal_places=2, max_digits=8)), + ('inner_area', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)), + ('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('sale_bottom_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('sale_record_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('rent_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('orientation', models.CharField(blank=True, choices=[('east', '东'), ('south', '南'), ('west', '西'), ('north', '北'), ('southeast', '东南'), ('northeast', '东北'), ('east_west', '东西'), ('south_north', '南北'), ('northwest', '西北'), ('southwest', '西南')], default='', max_length=15)), + ('decoration', models.CharField(blank=True, choices=[('rough', '毛坯'), ('plain', '清水'), ('simple', '简装'), ('medium', '中装'), ('fine', '精装'), ('luxury', '豪装')], default='', max_length=10)), + ('has_elevator', models.BooleanField(blank=True, null=True)), + ('built_year', models.SmallIntegerField(blank=True, null=True)), + ('usage_type', models.CharField(blank=True, default='', max_length=30)), + ('usage_subtype', models.CharField(blank=True, default='', max_length=30)), + ('shop_frontage', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('shop_depth', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('shop_height', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('shop_location', models.CharField(blank=True, choices=[('street', '临街商铺'), ('mall', '商场'), ('residential', '住宅底商'), ('ground_floor', '底层'), ('complex', '综合体')], default='', max_length=20)), + ('house_status', models.CharField(blank=True, choices=[('owner_occupied', '业主自住'), ('vacant', '空置'), ('tenant_occupied', '租客在住'), ('unknown', '未知')], default='', max_length=20)), + ('viewing_time', models.CharField(blank=True, choices=[('anytime', '随时看房'), ('by_appointment', '预约看房'), ('inconvenient', '不便看房')], default='', max_length=20)), + ('grade', models.CharField(blank=True, choices=[('a', 'A(急迫)'), ('b', 'B(较强)'), ('c', 'C(一般)'), ('d', 'D(较弱)')], default='', max_length=2)), + ('ownership_years', models.CharField(blank=True, default='', max_length=30)), + ('ownership_years_detail', models.CharField(blank=True, default='', max_length=20)), + ('ownership_nature', models.CharField(blank=True, choices=[('commercial', '商品房'), ('reform_housing', '房改房'), ('collective', '集资房'), ('economic', '经济适用房')], default='', max_length=20)), + ('is_only_house', models.BooleanField(blank=True, null=True)), + ('payment_method', models.CharField(blank=True, choices=[('full', '全款'), ('mortgage', '按揭'), ('installment', '分期'), ('advance', '垫资')], default='', max_length=15)), + ('tax_included', models.CharField(blank=True, choices=[('each_party', '各付'), ('net', '净到手'), ('inclusive', '包税')], default='', max_length=15)), + ('has_mortgage', models.BooleanField(blank=True, null=True)), + ('has_loan', models.BooleanField(blank=True, null=True)), + ('has_seal', models.BooleanField(blank=True, null=True)), + ('has_restriction', models.BooleanField(blank=True, null=True)), + ('original_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('sale_reason', models.TextField(blank=True, default='')), + ('remarks', models.TextField(blank=True, default='')), + ('source', models.CharField(blank=True, default='', max_length=50)), + ('completeness_score', models.SmallIntegerField(default=0)), + ('listed_at', models.DateTimeField(blank=True, null=True)), + ('last_followed_at', models.DateTimeField(blank=True, null=True)), + ('search_vector', django.contrib.postgres.search.SearchVectorField(blank=True, null=True)), + ('version', models.IntegerField(default=1)), + ('building', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='properties', to='fonrey_complex.building')), + ('buyer_agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='buying_properties', to='org.staff')), + ('complex', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='properties', to='fonrey_complex.complex')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_properties', to='org.staff')), + ('first_recorder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='first_recorded_properties', to='org.staff')), + ('number_holder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='held_properties', to='org.staff')), + ('seller_agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='selling_properties', to='org.staff')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_properties', to='org.staff')), + ], + options={ + 'db_table': 'properties', + }, + ), + migrations.CreateModel( + name='PropertyTag', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=50)), + ('color', models.CharField(blank=True, default='', max_length=7)), + ('is_system', models.BooleanField(default=False)), + ('sort_order', models.IntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'db_table': 'property_tags', + }, + ), + migrations.CreateModel( + name='PropertyProtection', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_protected', models.BooleanField(default=False)), + ('reason', models.TextField(blank=True, default='')), + ('start_at', models.DateTimeField(blank=True, null=True)), + ('end_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('property', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='protection', to='fonrey_property.property')), + ('set_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='org.staff')), + ], + options={ + 'db_table': 'property_protections', + }, + ), + migrations.CreateModel( + name='PropertyMarketing', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('marketing_title', models.CharField(blank=True, default='', max_length=30)), + ('core_selling_points', models.TextField(blank=True, default='')), + ('owner_attitude', models.TextField(blank=True, default='')), + ('layout_description', models.TextField(blank=True, default='')), + ('complex_description', models.TextField(blank=True, default='')), + ('ai_generated_points', models.BooleanField(default=False)), + ('ai_generated_attitude', models.BooleanField(default=False)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('property', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='marketing', to='fonrey_property.property')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='org.staff')), + ], + options={ + 'db_table': 'property_marketing', + }, + ), + migrations.CreateModel( + name='PropertyKey', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('key_type', models.CharField(choices=[('mechanical', '机械钥匙'), ('password', '密码钥匙')], max_length=20)), + ('holder_snapshot', models.JSONField(blank=True, null=True)), + ('is_other_agency', models.BooleanField(default=False)), + ('other_agency_info', models.CharField(blank=True, default='', max_length=30)), + ('remarks', models.TextField(blank=True, default='')), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_property_keys', to='org.staff')), + ('holder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='held_keys', to='org.staff')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='keys', to='fonrey_property.property')), + ('storage_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stored_keys', to='org.orgunit')), + ], + options={ + 'db_table': 'property_keys', + }, + ), + migrations.CreateModel( + name='PropertyFavorite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='fonrey_property.property')), + ('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_properties', to='org.staff')), + ], + options={ + 'db_table': 'property_favorites', + }, + ), + migrations.CreateModel( + name='PropertyContact', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('name', models.CharField(max_length=50)), + ('gender', models.CharField(choices=[('male', '先生'), ('female', '女士')], default='male', max_length=10)), + ('identity', models.CharField(choices=[('owner', '业主'), ('contact', '联系人'), ('subletter', '转租人'), ('tenant', '租客'), ('agent', '代理人'), ('corporate', '企业法人')], default='contact', max_length=20)), + ('phone_enc', models.BinaryField()), + ('phone_hash', models.CharField(max_length=64)), + ('phone2_enc', models.BinaryField(blank=True, null=True)), + ('phone2_hash', models.CharField(blank=True, default='', max_length=64)), + ('wechat', models.CharField(blank=True, default='', max_length=100)), + ('qq', models.CharField(blank=True, default='', max_length=20)), + ('remarks', models.TextField(blank=True, default='')), + ('is_number_holder', models.BooleanField(default=False)), + ('number_holder_approved_at', models.DateTimeField(blank=True, null=True)), + ('sort_order', models.IntegerField(default=0)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_property_contacts', to='org.staff')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='fonrey_property.property')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_property_contacts', to='org.staff')), + ], + options={ + 'db_table': 'property_contacts', + }, + ), + migrations.CreateModel( + name='PropertyCompleteness', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('score_core_info', models.SmallIntegerField(default=0)), + ('score_attachment', models.SmallIntegerField(default=0)), + ('score_survey', models.SmallIntegerField(default=0)), + ('score_vr', models.SmallIntegerField(default=0)), + ('score_key', models.SmallIntegerField(default=0)), + ('score_commission', models.SmallIntegerField(default=0)), + ('score_verification', models.SmallIntegerField(default=0)), + ('score_follow_up', models.SmallIntegerField(default=0)), + ('score_viewing', models.SmallIntegerField(default=0)), + ('score_other', models.SmallIntegerField(default=0)), + ('total_score', models.SmallIntegerField(default=0)), + ('calculated_at', models.DateTimeField(auto_now=True)), + ('property', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='completeness', to='fonrey_property.property')), + ], + options={ + 'db_table': 'property_completeness', + }, + ), + migrations.CreateModel( + name='PropertyCertificate', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('owner_name', models.CharField(blank=True, default='', max_length=100)), + ('owner_id_number', models.CharField(blank=True, default='', max_length=50)), + ('owner_cert_type', models.CharField(blank=True, default='', max_length=20)), + ('property_location', models.CharField(blank=True, default='', max_length=500)), + ('cert_status', models.CharField(blank=True, default='', max_length=30)), + ('cert_no', models.CharField(blank=True, default='', max_length=100)), + ('first_registered_at', models.DateField(blank=True, null=True)), + ('ownership_nature', models.CharField(blank=True, default='', max_length=30)), + ('land_nature', models.CharField(blank=True, default='', max_length=30)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('property', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='certificate', to='fonrey_property.property')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='org.staff')), + ], + options={ + 'db_table': 'property_certificates', + }, + ), + migrations.CreateModel( + name='PropertyAttachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('category', models.CharField(choices=[('id_card', '身份证件'), ('property_cert', '产权证明'), ('commission_letter', '委托书'), ('other', '其他')], default='other', max_length=20)), + ('file_key', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('file_size', models.IntegerField()), + ('file_type', models.CharField(blank=True, default='', max_length=50)), + ('sort_order', models.SmallIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='org.staff')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fonrey_property.property')), + ], + options={ + 'db_table': 'property_attachments', + }, + ), + migrations.CreateModel( + name='PriceChange', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('old_sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('new_sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('old_bottom_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('new_bottom_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('old_record_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('new_record_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('old_rent_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('new_rent_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('change_reason', models.TextField()), + ('changed_at', models.DateTimeField(auto_now_add=True)), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='org.staff')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='price_changes', to='fonrey_property.property')), + ], + options={ + 'db_table': 'price_changes', + }, + ), + migrations.CreateModel( + name='NumberHolderApproval', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')], default='pending', max_length=20)), + ('remarks', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('decided_at', models.DateTimeField(blank=True, null=True)), + ('applicant', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='nh_applications', to='org.staff')), + ('approver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nh_approvals', to='org.staff')), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='number_holder_approvals', to='fonrey_property.propertycontact')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='number_holder_approvals', to='fonrey_property.property')), + ], + options={ + 'db_table': 'number_holder_approvals', + }, + ), + migrations.CreateModel( + name='ListingHistory', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('listing_type', models.CharField(choices=[('for_sale', '出售挂牌'), ('for_rent', '出租挂牌')], max_length=20)), + ('status', models.CharField(choices=[('active', '生效中'), ('ended', '已结束')], default='active', max_length=10)), + ('sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('rent_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('sale_unit_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('ownership_years', models.CharField(blank=True, default='', max_length=30)), + ('is_only_house', models.BooleanField(blank=True, null=True)), + ('tax_included', models.CharField(blank=True, default='', max_length=15)), + ('sale_reason', models.TextField(blank=True, default='')), + ('seller_agent_snapshot', models.JSONField(blank=True, null=True)), + ('started_at', models.DateTimeField()), + ('ended_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='listing_histories', to='fonrey_property.property')), + ('seller_agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='org.staff')), + ], + options={ + 'db_table': 'listing_histories', + }, + ), + migrations.CreateModel( + name='KeyAttachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file_key', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fonrey_property.propertykey')), + ], + options={ + 'db_table': 'key_attachments', + }, + ), + migrations.CreateModel( + name='FollowLogRecording', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('follow_log_id', models.UUIDField()), + ('file_key', models.TextField()), + ('duration_seconds', models.IntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'follow_log_recordings', + 'indexes': [models.Index(fields=['follow_log_id'], name='idx_flr_log')], + }, + ), + migrations.CreateModel( + name='FollowLogAttachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('follow_log_id', models.UUIDField()), + ('file_key', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('file_size', models.IntegerField()), + ('file_type', models.CharField(blank=True, choices=[('bmp', 'BMP'), ('jpg', 'JPG'), ('png', 'PNG'), ('svg', 'SVG'), ('gif', 'GIF')], default='', max_length=10)), + ('sort_order', models.SmallIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'follow_log_attachments', + 'indexes': [models.Index(fields=['follow_log_id'], name='idx_fla_log')], + }, + ), + migrations.CreateModel( + name='FieldSurvey', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('draft', '草稿'), ('submitted', '已提交')], default='draft', max_length=10)), + ('gps_latitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('gps_longitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)), + ('gps_accuracy', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('description', models.TextField(blank=True, default='')), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='org.staff')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_surveys', to='fonrey_property.property')), + ], + options={ + 'db_table': 'field_surveys', + }, + ), + migrations.CreateModel( + name='CommissionAttachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('category', models.CharField(choices=[('id_card', '身份证件'), ('property_cert', '产权证明'), ('commission_letter', '委托书'), ('other', '其他')], max_length=20)), + ('file_key', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('file_size', models.IntegerField(blank=True, null=True)), + ('sort_order', models.SmallIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('commission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fonrey_property.commission')), + ], + options={ + 'db_table': 'commission_attachments', + }, + ), + migrations.AddField( + model_name='commission', + name='property', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='fonrey_property.property'), + ), + migrations.AddField( + model_name='commission', + name='property_owner_contact', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='commissions', to='fonrey_property.propertycontact'), + ), + migrations.CreateModel( + name='SurveyPhoto', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('category', models.CharField(choices=[('layout', '户型图'), ('living_room', '客厅'), ('dining_room', '餐厅'), ('bedroom', '卧室'), ('bathroom', '卫生间'), ('kitchen', '厨房'), ('entrance', '入户'), ('balcony', '阳台'), ('study', '书房'), ('indoor_other', '室内其他'), ('outdoor', '室外')], max_length=20)), + ('file_key', models.TextField()), + ('thumbnail_key', models.TextField(blank=True, default='')), + ('file_size', models.IntegerField(blank=True, null=True)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('sort_order', models.SmallIntegerField(default=0)), + ('is_vr_screenshot', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('survey', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fonrey_property.fieldsurvey')), + ], + options={ + 'db_table': 'survey_photos', + 'indexes': [models.Index(fields=['survey'], name='idx_sp_survey'), models.Index(fields=['survey', 'category'], name='idx_sp_category')], + }, + ), + migrations.CreateModel( + name='PropertyTagRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tag_relations', to='fonrey_property.property')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='property_relations', to='fonrey_property.propertytag')), + ], + options={ + 'db_table': 'property_tag_relations', + 'indexes': [models.Index(fields=['property'], name='idx_ptr_property'), models.Index(fields=['tag'], name='idx_ptr_tag')], + }, + ), + migrations.AddConstraint( + model_name='propertytagrelation', + constraint=models.UniqueConstraint(fields=('property', 'tag'), name='uq_ptr_property_tag'), + ), + migrations.AddIndex( + model_name='propertykey', + index=models.Index(fields=['property'], name='idx_pk_property'), + ), + migrations.AddIndex( + model_name='propertyfavorite', + index=models.Index(fields=['staff'], name='idx_pfav_staff'), + ), + migrations.AddConstraint( + model_name='propertyfavorite', + constraint=models.UniqueConstraint(fields=('staff', 'property'), name='uq_pfav_staff_property'), + ), + migrations.AddIndex( + model_name='propertycontact', + index=models.Index(fields=['property'], name='idx_pc_property'), + ), + migrations.AddIndex( + model_name='propertycontact', + index=models.Index(fields=['phone_hash'], name='idx_pc_phone_hash'), + ), + migrations.AddIndex( + model_name='propertycontact', + index=models.Index(fields=['phone2_hash'], name='idx_pc_phone2_hash'), + ), + migrations.AddIndex( + model_name='propertyattachment', + index=models.Index(fields=['property'], name='idx_pa_property'), + ), + migrations.AddIndex( + model_name='propertyattachment', + index=models.Index(fields=['property', 'category'], name='idx_pa_category'), + ), + migrations.AddIndex( + model_name='property', + index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='idx_properties_search'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['complex'], name='idx_properties_complex'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['status'], name='idx_properties_status'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['sale_price'], name='idx_properties_sale_price'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['area'], name='idx_properties_area'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['listed_at'], name='idx_properties_listed_at'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['last_followed_at'], name='idx_properties_last_followed'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['bedroom_count'], name='idx_properties_bedroom'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['grade'], name='idx_properties_grade'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['completeness_score'], name='idx_properties_completeness'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['seller_agent'], name='idx_properties_seller_agent'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['number_holder'], name='idx_properties_number_holder'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['status', 'attribute', 'complex', 'sale_price'], name='idx_properties_list_composite'), + ), + migrations.AddIndex( + model_name='property', + index=models.Index(fields=['seller_agent', 'status', 'listed_at'], name='idx_properties_my_properties'), + ), + migrations.AddConstraint( + model_name='property', + constraint=models.CheckConstraint(check=models.Q(('floor__gt', 0), ('floor__lte', models.F('total_floors'))), name='chk_property_floor'), + ), + migrations.AddIndex( + model_name='pricechange', + index=models.Index(fields=['property'], name='idx_pchg_property'), + ), + migrations.AddIndex( + model_name='pricechange', + index=models.Index(fields=['property', '-changed_at'], name='idx_pchg_time'), + ), + migrations.AddIndex( + model_name='numberholderapproval', + index=models.Index(fields=['status'], name='idx_nha_status'), + ), + migrations.AddIndex( + model_name='numberholderapproval', + index=models.Index(fields=['property'], name='idx_nha_property'), + ), + migrations.AddIndex( + model_name='listinghistory', + index=models.Index(fields=['property'], name='idx_lh_property'), + ), + migrations.AddIndex( + model_name='listinghistory', + index=models.Index(fields=['property', 'status'], name='idx_lh_active'), + ), + migrations.AddIndex( + model_name='keyattachment', + index=models.Index(fields=['key'], name='idx_ka_key'), + ), + migrations.AddIndex( + model_name='fieldsurvey', + index=models.Index(fields=['property'], name='idx_fs_property'), + ), + migrations.AddIndex( + model_name='fieldsurvey', + index=models.Index(fields=['property', 'status'], name='idx_fs_submitted'), + ), + migrations.AddIndex( + model_name='commissionattachment', + index=models.Index(fields=['commission'], name='idx_ca_commission'), + ), + migrations.AddIndex( + model_name='commission', + index=models.Index(fields=['property'], name='idx_commissions_property'), + ), + migrations.AddIndex( + model_name='commission', + index=models.Index(fields=['property', 'status'], name='idx_commissions_active'), + ), + ] diff --git a/apps/property/migrations/0002_partitions_and_triggers.py b/apps/property/migrations/0002_partitions_and_triggers.py new file mode 100644 index 0000000..e769a15 --- /dev/null +++ b/apps/property/migrations/0002_partitions_and_triggers.py @@ -0,0 +1,136 @@ +from django.db import migrations + +CREATE_FOLLOW_LOGS = """ +CREATE TABLE follow_logs ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + log_type VARCHAR(30) NOT NULL + CHECK (log_type IN ('written','modified','sensitive_op', + 'sensitive_view','other','system')), + purpose VARCHAR(50), + content TEXT, + ai_tag VARCHAR(20) + CHECK (ai_tag IS NULL OR ai_tag IN ('ai_for_sale','ai_not_for_sale')), + change_detail JSONB, + log_tag VARCHAR(50), + is_public BOOLEAN NOT NULL DEFAULT TRUE, + operator_id UUID REFERENCES staff(id) ON DELETE SET NULL, + operator_snapshot JSONB, + is_deletable BOOLEAN NOT NULL DEFAULT TRUE, + deleted_at TIMESTAMPTZ, + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); + +CREATE TABLE follow_logs_2026_04 PARTITION OF follow_logs + FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); +CREATE TABLE follow_logs_2026_05 PARTITION OF follow_logs + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); +CREATE TABLE follow_logs_default PARTITION OF follow_logs DEFAULT; + +CREATE INDEX idx_follow_logs_property_time ON follow_logs(property_id, created_at DESC) + WHERE deleted_at IS NULL; +CREATE INDEX idx_follow_logs_type ON follow_logs(property_id, log_type, created_at DESC) + WHERE deleted_at IS NULL; +CREATE INDEX idx_follow_logs_operator ON follow_logs(operator_id, created_at DESC) + WHERE deleted_at IS NULL; +CREATE INDEX idx_follow_logs_sensitive ON follow_logs(property_id, created_at DESC) + WHERE log_type IN ('sensitive_view','sensitive_op'); +""" + +DROP_FOLLOW_LOGS = "DROP TABLE IF EXISTS follow_logs CASCADE;" + +CREATE_PROPERTY_PHOTOS = """ +CREATE TABLE property_photos ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + category VARCHAR(20) NOT NULL + CHECK (category IN ('cover','entrance','living_room', + 'dining_room','bedroom','bathroom', + 'kitchen','balcony','study', + 'indoor_other','outdoor','panorama')), + file_key TEXT NOT NULL, + thumbnail_key TEXT, + file_name VARCHAR(255), + file_size INTEGER, + width INTEGER, + height INTEGER, + is_cover BOOLEAN NOT NULL DEFAULT FALSE, + sort_order SMALLINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by_id UUID REFERENCES staff(id) ON DELETE SET NULL, + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); + +CREATE TABLE property_photos_2026_04 PARTITION OF property_photos + FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); +CREATE TABLE property_photos_2026_05 PARTITION OF property_photos + FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); +CREATE TABLE property_photos_default PARTITION OF property_photos DEFAULT; + +CREATE INDEX idx_property_photos_property ON property_photos(property_id); +CREATE INDEX idx_property_photos_cover ON property_photos(property_id) + WHERE is_cover = TRUE; +CREATE INDEX idx_property_photos_category ON property_photos(property_id, category); +CREATE UNIQUE INDEX idx_property_photos_unique_cover + ON property_photos(property_id) + WHERE is_cover = TRUE; +""" + +DROP_PROPERTY_PHOTOS = "DROP TABLE IF EXISTS property_photos CASCADE;" + +CREATE_TRIGGERS = """ +CREATE OR REPLACE FUNCTION update_property_search_vector() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('simple', COALESCE(NEW.block_no, '') || + ' ' || COALESCE(NEW.unit_no, '') || + ' ' || COALESCE(NEW.room_no, '')), 'A') || + setweight(to_tsvector('simple', COALESCE(NEW.remarks, '')), 'C'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_property_search_vector + BEFORE INSERT OR UPDATE OF block_no, unit_no, room_no, remarks + ON properties + FOR EACH ROW EXECUTE FUNCTION update_property_search_vector(); + +CREATE OR REPLACE FUNCTION update_property_last_followed() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.log_type = 'written' THEN + UPDATE properties + SET last_followed_at = NEW.created_at, + updated_at = NOW() + WHERE id = NEW.property_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_last_followed + AFTER INSERT ON follow_logs + FOR EACH ROW EXECUTE FUNCTION update_property_last_followed(); +""" + +DROP_TRIGGERS = """ +DROP TRIGGER IF EXISTS trg_update_last_followed ON follow_logs; +DROP FUNCTION IF EXISTS update_property_last_followed(); +DROP TRIGGER IF EXISTS trg_property_search_vector ON properties; +DROP FUNCTION IF EXISTS update_property_search_vector(); +""" + + +class Migration(migrations.Migration): + dependencies = [ + ("fonrey_property", "0001_initial"), + ] + + operations = [ + migrations.RunSQL(CREATE_FOLLOW_LOGS, reverse_sql=DROP_FOLLOW_LOGS), + migrations.RunSQL(CREATE_PROPERTY_PHOTOS, reverse_sql=DROP_PROPERTY_PHOTOS), + migrations.RunSQL(CREATE_TRIGGERS, reverse_sql=DROP_TRIGGERS), + ] diff --git a/apps/property/models/__init__.py b/apps/property/models/__init__.py index e69de29..a0a6181 100644 --- a/apps/property/models/__init__.py +++ b/apps/property/models/__init__.py @@ -0,0 +1,57 @@ +from apps.property.models.core import ( + Property, + PropertyCertificate, + PropertyCompleteness, + PropertyContact, + PropertyMarketing, + PropertyProtection, +) +from apps.property.models.follow_keys import ( + FollowLog, + FollowLogAttachment, + FollowLogRecording, + KeyAttachment, + PropertyKey, +) +from apps.property.models.listings import ( + Commission, + CommissionAttachment, + ListingHistory, + NumberHolderApproval, + PriceChange, +) +from apps.property.models.media import ( + FieldSurvey, + PropertyAttachment, + PropertyFavorite, + PropertyPhoto, + PropertyTag, + PropertyTagRelation, + SurveyPhoto, +) + +__all__ = [ + "Commission", + "CommissionAttachment", + "FieldSurvey", + "FollowLog", + "FollowLogAttachment", + "FollowLogRecording", + "KeyAttachment", + "ListingHistory", + "NumberHolderApproval", + "PriceChange", + "Property", + "PropertyAttachment", + "PropertyCertificate", + "PropertyCompleteness", + "PropertyContact", + "PropertyFavorite", + "PropertyKey", + "PropertyMarketing", + "PropertyPhoto", + "PropertyProtection", + "PropertyTag", + "PropertyTagRelation", + "SurveyPhoto", +] diff --git a/apps/property/models/core.py b/apps/property/models/core.py new file mode 100644 index 0000000..51c6240 --- /dev/null +++ b/apps/property/models/core.py @@ -0,0 +1,338 @@ +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVectorField +from django.db import models + +from core.enums import ( + PropertyAttribute, + PropertyContactGender, + PropertyContactIdentity, + PropertyDecoration, + PropertyGrade, + PropertyHouseStatus, + PropertyOrientation, + PropertyOwnershipNature, + PropertyPaymentMethod, + PropertyShopLocation, + PropertyStatus, + PropertyTaxIncluded, + PropertyType, + PropertyViewingTime, +) +from core.models.base import SoftDeleteModel, TimeStampedModel, UUIDPrimaryKeyModel + + +class Property(SoftDeleteModel): + property_type = models.CharField(max_length=30, choices=PropertyType.choices) + status = models.CharField( + max_length=20, + choices=PropertyStatus.choices, + default=PropertyStatus.FOR_SALE, + ) + attribute = models.CharField( + max_length=10, + choices=PropertyAttribute.choices, + default=PropertyAttribute.PUBLIC, + ) + private_reason = models.TextField(blank=True, default="") + + complex = models.ForeignKey( + "fonrey_complex.Complex", + on_delete=models.RESTRICT, + related_name="properties", + ) + building = models.ForeignKey( + "fonrey_complex.Building", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="properties", + ) + block_no = models.CharField(max_length=30, blank=True, default="") + unit_no = models.CharField(max_length=30, blank=True, default="") + room_no = models.CharField(max_length=30, blank=True, default="") + floor = models.SmallIntegerField() + total_floors = models.SmallIntegerField() + + bedroom_count = models.SmallIntegerField(default=0) + living_room_count = models.SmallIntegerField(default=0) + bathroom_count = models.SmallIntegerField(default=0) + kitchen_count = models.SmallIntegerField(default=0) + balcony_count = models.SmallIntegerField(default=0) + + area = models.DecimalField(max_digits=8, decimal_places=2) + inner_area = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True) + + sale_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + sale_bottom_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + sale_record_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + rent_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + + orientation = models.CharField( + max_length=15, blank=True, default="", choices=PropertyOrientation.choices + ) + decoration = models.CharField( + max_length=10, blank=True, default="", choices=PropertyDecoration.choices + ) + has_elevator = models.BooleanField(null=True, blank=True) + built_year = models.SmallIntegerField(null=True, blank=True) + + usage_type = models.CharField(max_length=30, blank=True, default="") + usage_subtype = models.CharField(max_length=30, blank=True, default="") + + shop_frontage = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) + shop_depth = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) + shop_height = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) + shop_location = models.CharField( + max_length=20, blank=True, default="", choices=PropertyShopLocation.choices + ) + + house_status = models.CharField( + max_length=20, blank=True, default="", choices=PropertyHouseStatus.choices + ) + viewing_time = models.CharField( + max_length=20, blank=True, default="", choices=PropertyViewingTime.choices + ) + + grade = models.CharField(max_length=2, blank=True, default="", choices=PropertyGrade.choices) + + ownership_years = models.CharField(max_length=30, blank=True, default="") + ownership_years_detail = models.CharField(max_length=20, blank=True, default="") + ownership_nature = models.CharField( + max_length=20, blank=True, default="", choices=PropertyOwnershipNature.choices + ) + is_only_house = models.BooleanField(null=True, blank=True) + payment_method = models.CharField( + max_length=15, blank=True, default="", choices=PropertyPaymentMethod.choices + ) + tax_included = models.CharField( + max_length=15, blank=True, default="", choices=PropertyTaxIncluded.choices + ) + has_mortgage = models.BooleanField(null=True, blank=True) + has_loan = models.BooleanField(null=True, blank=True) + has_seal = models.BooleanField(null=True, blank=True) + has_restriction = models.BooleanField(null=True, blank=True) + original_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + sale_reason = models.TextField(blank=True, default="") + + remarks = models.TextField(blank=True, default="") + + first_recorder = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="first_recorded_properties", + ) + number_holder = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="held_properties", + ) + seller_agent = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="selling_properties", + ) + buyer_agent = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="buying_properties", + ) + + source = models.CharField(max_length=50, blank=True, default="") + + completeness_score = models.SmallIntegerField(default=0) + + listed_at = models.DateTimeField(null=True, blank=True) + last_followed_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_properties", + ) + updated_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="updated_properties", + ) + + search_vector = SearchVectorField(null=True, blank=True) + version = models.IntegerField(default=1) + + class Meta: + db_table = "properties" + constraints = [ + models.CheckConstraint( + check=models.Q(floor__gt=0) & models.Q(floor__lte=models.F("total_floors")), + name="chk_property_floor", + ), + ] + indexes = [ + GinIndex(fields=["search_vector"], name="idx_properties_search"), + models.Index(fields=["complex"], name="idx_properties_complex"), + models.Index(fields=["status"], name="idx_properties_status"), + models.Index(fields=["sale_price"], name="idx_properties_sale_price"), + models.Index(fields=["area"], name="idx_properties_area"), + models.Index(fields=["listed_at"], name="idx_properties_listed_at"), + models.Index(fields=["last_followed_at"], name="idx_properties_last_followed"), + models.Index(fields=["bedroom_count"], name="idx_properties_bedroom"), + models.Index(fields=["grade"], name="idx_properties_grade"), + models.Index(fields=["completeness_score"], name="idx_properties_completeness"), + models.Index(fields=["seller_agent"], name="idx_properties_seller_agent"), + models.Index(fields=["number_holder"], name="idx_properties_number_holder"), + models.Index( + fields=["status", "attribute", "complex", "sale_price"], + name="idx_properties_list_composite", + ), + models.Index( + fields=["seller_agent", "status", "listed_at"], + name="idx_properties_my_properties", + ), + ] + + +class PropertyContact(SoftDeleteModel): + property = models.ForeignKey( + Property, on_delete=models.CASCADE, related_name="contacts" + ) + name = models.CharField(max_length=50) + gender = models.CharField( + max_length=10, choices=PropertyContactGender.choices, default=PropertyContactGender.MALE + ) + identity = models.CharField( + max_length=20, + choices=PropertyContactIdentity.choices, + default=PropertyContactIdentity.CONTACT, + ) + + phone_enc = models.BinaryField() + phone_hash = models.CharField(max_length=64) + 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.TextField(blank=True, default="") + + is_number_holder = models.BooleanField(default=False) + number_holder_approved_at = models.DateTimeField(null=True, blank=True) + + sort_order = models.IntegerField(default=0) + created_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_property_contacts", + ) + updated_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="updated_property_contacts", + ) + + class Meta: + db_table = "property_contacts" + indexes = [ + models.Index(fields=["property"], name="idx_pc_property"), + models.Index(fields=["phone_hash"], name="idx_pc_phone_hash"), + models.Index(fields=["phone2_hash"], name="idx_pc_phone2_hash"), + ] + + +class PropertyMarketing(UUIDPrimaryKeyModel): + property = models.OneToOneField( + Property, on_delete=models.CASCADE, related_name="marketing" + ) + marketing_title = models.CharField(max_length=30, blank=True, default="") + core_selling_points = models.TextField(blank=True, default="") + owner_attitude = models.TextField(blank=True, default="") + layout_description = models.TextField(blank=True, default="") + complex_description = models.TextField(blank=True, default="") + + ai_generated_points = models.BooleanField(default=False) + ai_generated_attitude = models.BooleanField(default=False) + + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.ForeignKey( + "org.Staff", null=True, blank=True, on_delete=models.SET_NULL + ) + + class Meta: + db_table = "property_marketing" + + +class PropertyCertificate(UUIDPrimaryKeyModel): + property = models.OneToOneField( + Property, on_delete=models.CASCADE, related_name="certificate" + ) + owner_name = models.CharField(max_length=100, blank=True, default="") + owner_id_number = models.CharField(max_length=50, blank=True, default="") + owner_cert_type = models.CharField(max_length=20, blank=True, default="") + property_location = models.CharField(max_length=500, blank=True, default="") + + cert_status = models.CharField(max_length=30, blank=True, default="") + cert_no = models.CharField(max_length=100, blank=True, default="") + first_registered_at = models.DateField(null=True, blank=True) + ownership_nature = models.CharField(max_length=30, blank=True, default="") + land_nature = models.CharField(max_length=30, blank=True, default="") + + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.ForeignKey( + "org.Staff", null=True, blank=True, on_delete=models.SET_NULL + ) + + class Meta: + db_table = "property_certificates" + + +class PropertyCompleteness(UUIDPrimaryKeyModel): + property = models.OneToOneField( + Property, on_delete=models.CASCADE, related_name="completeness" + ) + score_core_info = models.SmallIntegerField(default=0) + score_attachment = models.SmallIntegerField(default=0) + score_survey = models.SmallIntegerField(default=0) + score_vr = models.SmallIntegerField(default=0) + score_key = models.SmallIntegerField(default=0) + score_commission = models.SmallIntegerField(default=0) + score_verification = models.SmallIntegerField(default=0) + score_follow_up = models.SmallIntegerField(default=0) + score_viewing = models.SmallIntegerField(default=0) + score_other = models.SmallIntegerField(default=0) + total_score = models.SmallIntegerField(default=0) + + calculated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "property_completeness" + + +class PropertyProtection(UUIDPrimaryKeyModel): + property = models.OneToOneField( + Property, on_delete=models.CASCADE, related_name="protection" + ) + is_protected = models.BooleanField(default=False) + reason = models.TextField(blank=True, default="") + start_at = models.DateTimeField(null=True, blank=True) + end_at = models.DateTimeField(null=True, blank=True) + set_by = models.ForeignKey( + "org.Staff", null=True, blank=True, on_delete=models.SET_NULL + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "property_protections" diff --git a/apps/property/models/follow_keys.py b/apps/property/models/follow_keys.py new file mode 100644 index 0000000..a657c9b --- /dev/null +++ b/apps/property/models/follow_keys.py @@ -0,0 +1,130 @@ +from django.db import models + +from core.enums import ( + PropertyFollowAiTag, + PropertyFollowAttachmentFileType, + PropertyFollowLogType, + PropertyKeyType, +) +from core.models.base import UUIDPrimaryKeyModel + + +class FollowLog(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() + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="follow_logs" + ) + + log_type = models.CharField(max_length=30, choices=PropertyFollowLogType.choices) + purpose = models.CharField(max_length=50, blank=True, default="") + content = models.TextField(blank=True, default="") + + ai_tag = models.CharField( + max_length=20, blank=True, default="", choices=PropertyFollowAiTag.choices + ) + + change_detail = models.JSONField(null=True, blank=True) + log_tag = models.CharField(max_length=50, blank=True, default="") + + is_public = 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) + + is_deletable = models.BooleanField(default=True) + deleted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "follow_logs" + managed = False + unique_together = (("id", "created_at"),) + + +class FollowLogAttachment(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="", choices=PropertyFollowAttachmentFileType.choices + ) + sort_order = models.SmallIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "follow_log_attachments" + indexes = [models.Index(fields=["follow_log_id"], name="idx_fla_log")] + + +class FollowLogRecording(UUIDPrimaryKeyModel): + follow_log_id = models.UUIDField() + file_key = models.TextField() + duration_seconds = models.IntegerField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "follow_log_recordings" + indexes = [models.Index(fields=["follow_log_id"], name="idx_flr_log")] + + +class PropertyKey(UUIDPrimaryKeyModel): + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="keys" + ) + key_type = models.CharField(max_length=20, choices=PropertyKeyType.choices) + + holder = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="held_keys", + ) + holder_snapshot = models.JSONField(null=True, blank=True) + storage_unit = models.ForeignKey( + "org.OrgUnit", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="stored_keys", + ) + + is_other_agency = models.BooleanField(default=False) + other_agency_info = models.CharField(max_length=30, blank=True, default="") + remarks = models.TextField(blank=True, default="") + + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_property_keys", + ) + + class Meta: + db_table = "property_keys" + indexes = [models.Index(fields=["property"], name="idx_pk_property")] + + +class KeyAttachment(UUIDPrimaryKeyModel): + key = models.ForeignKey( + PropertyKey, on_delete=models.CASCADE, related_name="attachments" + ) + file_key = models.TextField() + file_name = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "key_attachments" + indexes = [models.Index(fields=["key"], name="idx_ka_key")] diff --git a/apps/property/models/listings.py b/apps/property/models/listings.py new file mode 100644 index 0000000..aef06fd --- /dev/null +++ b/apps/property/models/listings.py @@ -0,0 +1,194 @@ +from django.db import models + +from core.enums import ( + PropertyCommissionAttachmentCategory, + PropertyCommissionOwnerType, + PropertyCommissionStatus, + PropertyListingHistoryStatus, + PropertyListingType, + PropertyNumberHolderApprovalStatus, +) +from core.models.base import TimeStampedModel, UUIDPrimaryKeyModel + + +class ListingHistory(UUIDPrimaryKeyModel): + property = models.ForeignKey( + "fonrey_property.Property", + on_delete=models.RESTRICT, + related_name="listing_histories", + ) + listing_type = models.CharField(max_length=20, choices=PropertyListingType.choices) + status = models.CharField( + max_length=10, + choices=PropertyListingHistoryStatus.choices, + default=PropertyListingHistoryStatus.ACTIVE, + ) + + sale_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + rent_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + sale_unit_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + + ownership_years = models.CharField(max_length=30, blank=True, default="") + is_only_house = models.BooleanField(null=True, blank=True) + tax_included = models.CharField(max_length=15, blank=True, default="") + sale_reason = models.TextField(blank=True, default="") + + seller_agent = models.ForeignKey( + "org.Staff", null=True, blank=True, on_delete=models.SET_NULL + ) + seller_agent_snapshot = models.JSONField(null=True, blank=True) + + started_at = models.DateTimeField(auto_now_add=False) + ended_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "listing_histories" + indexes = [ + models.Index(fields=["property"], name="idx_lh_property"), + models.Index(fields=["property", "status"], name="idx_lh_active"), + ] + + +class PriceChange(UUIDPrimaryKeyModel): + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.RESTRICT, related_name="price_changes" + ) + old_sale_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + new_sale_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + old_bottom_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + new_bottom_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + old_record_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + new_record_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + old_rent_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + new_rent_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + + change_reason = models.TextField() + changed_at = models.DateTimeField(auto_now_add=True) + changed_by = models.ForeignKey("org.Staff", on_delete=models.RESTRICT) + + class Meta: + db_table = "price_changes" + indexes = [ + models.Index(fields=["property"], name="idx_pchg_property"), + models.Index(fields=["property", "-changed_at"], name="idx_pchg_time"), + ] + + +class Commission(TimeStampedModel): + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="commissions" + ) + commission_type = models.CharField(max_length=50) + period_start = models.DateField() + period_end = models.DateField(null=True, blank=True) + is_open_ended = models.BooleanField(default=False) + + agent = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="commissions_as_agent", + ) + agent_snapshot = models.JSONField(null=True, blank=True) + + signing_method = models.CharField(max_length=50, blank=True, default="") + + owner_type = models.CharField( + max_length=20, + choices=PropertyCommissionOwnerType.choices, + default=PropertyCommissionOwnerType.OWNER, + ) + property_owner_contact = models.ForeignKey( + "fonrey_property.PropertyContact", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="commissions", + ) + owner_name = models.CharField(max_length=50, blank=True, default="") + owner_id_type = models.CharField(max_length=20, blank=True, default="") + owner_id_number = models.CharField(max_length=50, blank=True, default="") + owner_id_number_enc = models.BinaryField(null=True, blank=True) + + remarks = models.TextField(blank=True, default="") + + status = models.CharField( + max_length=20, + choices=PropertyCommissionStatus.choices, + default=PropertyCommissionStatus.ACTIVE, + ) + + created_by = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_commissions", + ) + + class Meta: + db_table = "commissions" + indexes = [ + models.Index(fields=["property"], name="idx_commissions_property"), + models.Index(fields=["property", "status"], name="idx_commissions_active"), + ] + + +class CommissionAttachment(UUIDPrimaryKeyModel): + commission = models.ForeignKey( + Commission, on_delete=models.CASCADE, related_name="attachments" + ) + category = models.CharField( + max_length=20, choices=PropertyCommissionAttachmentCategory.choices + ) + file_key = models.TextField() + file_name = models.CharField(max_length=255) + file_size = models.IntegerField(null=True, blank=True) + sort_order = models.SmallIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "commission_attachments" + indexes = [models.Index(fields=["commission"], name="idx_ca_commission")] + + +class NumberHolderApproval(UUIDPrimaryKeyModel): + property = models.ForeignKey( + "fonrey_property.Property", + on_delete=models.CASCADE, + related_name="number_holder_approvals", + ) + contact = models.ForeignKey( + "fonrey_property.PropertyContact", + on_delete=models.CASCADE, + related_name="number_holder_approvals", + ) + + applicant = models.ForeignKey( + "org.Staff", on_delete=models.RESTRICT, related_name="nh_applications" + ) + approver = models.ForeignKey( + "org.Staff", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="nh_approvals", + ) + + status = models.CharField( + max_length=20, + choices=PropertyNumberHolderApprovalStatus.choices, + default=PropertyNumberHolderApprovalStatus.PENDING, + ) + remarks = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + decided_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "number_holder_approvals" + indexes = [ + models.Index(fields=["status"], name="idx_nha_status"), + models.Index(fields=["property"], name="idx_nha_property"), + ] diff --git a/apps/property/models/media.py b/apps/property/models/media.py new file mode 100644 index 0000000..a57cf76 --- /dev/null +++ b/apps/property/models/media.py @@ -0,0 +1,166 @@ +from django.db import models + +from core.enums import ( + PropertyAttachmentCategory, + PropertyFieldSurveyStatus, + PropertyPhotoCategory, + PropertySurveyPhotoCategory, +) +from core.models.base import UUIDPrimaryKeyModel + + +class FieldSurvey(UUIDPrimaryKeyModel): + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="field_surveys" + ) + status = models.CharField( + max_length=10, + choices=PropertyFieldSurveyStatus.choices, + default=PropertyFieldSurveyStatus.DRAFT, + ) + + gps_latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + gps_longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True) + gps_accuracy = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) + + description = models.TextField(blank=True, default="") + + submitted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey("org.Staff", on_delete=models.RESTRICT) + + class Meta: + db_table = "field_surveys" + indexes = [ + models.Index(fields=["property"], name="idx_fs_property"), + models.Index(fields=["property", "status"], name="idx_fs_submitted"), + ] + + +class SurveyPhoto(UUIDPrimaryKeyModel): + survey = models.ForeignKey(FieldSurvey, on_delete=models.CASCADE, related_name="photos") + category = models.CharField(max_length=20, choices=PropertySurveyPhotoCategory.choices) + file_key = models.TextField() + thumbnail_key = models.TextField(blank=True, default="") + file_size = models.IntegerField(null=True, blank=True) + width = models.IntegerField(null=True, blank=True) + height = models.IntegerField(null=True, blank=True) + sort_order = models.SmallIntegerField(default=0) + is_vr_screenshot = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "survey_photos" + indexes = [ + models.Index(fields=["survey"], name="idx_sp_survey"), + models.Index(fields=["survey", "category"], name="idx_sp_category"), + ] + + +class PropertyPhoto(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() + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="photos" + ) + + category = models.CharField(max_length=20, choices=PropertyPhotoCategory.choices) + file_key = models.TextField() + thumbnail_key = models.TextField(blank=True, default="") + file_name = models.CharField(max_length=255, blank=True, default="") + file_size = models.IntegerField(null=True, blank=True) + width = models.IntegerField(null=True, blank=True) + height = models.IntegerField(null=True, blank=True) + + is_cover = models.BooleanField(default=False) + sort_order = models.SmallIntegerField(default=0) + + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + "org.Staff", null=True, blank=True, on_delete=models.SET_NULL + ) + + class Meta: + db_table = "property_photos" + managed = False + unique_together = (("id", "created_at"),) + + +class PropertyAttachment(UUIDPrimaryKeyModel): + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="attachments" + ) + category = models.CharField( + max_length=20, + choices=PropertyAttachmentCategory.choices, + default=PropertyAttachmentCategory.OTHER, + ) + file_key = models.TextField() + file_name = models.CharField(max_length=255) + file_size = models.IntegerField() + file_type = models.CharField(max_length=50, blank=True, default="") + sort_order = models.SmallIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + "org.Staff", null=True, blank=True, on_delete=models.SET_NULL + ) + + class Meta: + db_table = "property_attachments" + indexes = [ + models.Index(fields=["property"], name="idx_pa_property"), + models.Index(fields=["property", "category"], name="idx_pa_category"), + ] + + +class PropertyTag(UUIDPrimaryKeyModel): + name = models.CharField(max_length=50) + color = models.CharField(max_length=7, blank=True, default="") + is_system = models.BooleanField(default=False) + sort_order = models.IntegerField(default=0) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "property_tags" + + +class PropertyTagRelation(models.Model): + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="tag_relations" + ) + tag = models.ForeignKey( + PropertyTag, on_delete=models.CASCADE, related_name="property_relations" + ) + + class Meta: + db_table = "property_tag_relations" + constraints = [ + models.UniqueConstraint(fields=["property", "tag"], name="uq_ptr_property_tag"), + ] + indexes = [ + models.Index(fields=["property"], name="idx_ptr_property"), + models.Index(fields=["tag"], name="idx_ptr_tag"), + ] + + +class PropertyFavorite(models.Model): + staff = models.ForeignKey( + "org.Staff", on_delete=models.CASCADE, related_name="favorite_properties" + ) + property = models.ForeignKey( + "fonrey_property.Property", on_delete=models.CASCADE, related_name="favorited_by" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "property_favorites" + constraints = [ + models.UniqueConstraint(fields=["staff", "property"], name="uq_pfav_staff_property"), + ] + indexes = [models.Index(fields=["staff"], name="idx_pfav_staff")] diff --git a/core/enums.py b/core/enums.py index de7dfc8..c2bae77 100644 --- a/core/enums.py +++ b/core/enums.py @@ -439,6 +439,34 @@ class PropertyGrade(models.TextChoices): D = "d", "D(较弱)" +class PropertyShopLocation(models.TextChoices): + STREET = "street", "临街商铺" + MALL = "mall", "商场" + RESIDENTIAL = "residential", "住宅底商" + GROUND_FLOOR = "ground_floor", "底层" + COMPLEX = "complex", "综合体" + + +class PropertyOwnershipNature(models.TextChoices): + COMMERCIAL = "commercial", "商品房" + REFORM_HOUSING = "reform_housing", "房改房" + COLLECTIVE = "collective", "集资房" + ECONOMIC = "economic", "经济适用房" + + +class PropertyPaymentMethod(models.TextChoices): + FULL = "full", "全款" + MORTGAGE = "mortgage", "按揭" + INSTALLMENT = "installment", "分期" + ADVANCE = "advance", "垫资" + + +class PropertyTaxIncluded(models.TextChoices): + EACH_PARTY = "each_party", "各付" + NET = "net", "净到手" + INCLUSIVE = "inclusive", "包税" + + class PropertyContactGender(models.TextChoices): MALE = "male", "先生" FEMALE = "female", "女士"