Compare commits

...

16 Commits

Author SHA1 Message Date
5dedd19c0a docker file & docker compose change 2026-04-30 12:38:32 +08:00
d00ff12ba0 feat(migrations): add Phase 4.0+4.1 verbose_name/help_text migrations
Generated by manage.py makemigrations after Phase 4.0 (model Meta verbose_name)
and Phase 4.1 (field-level verbose_name/help_text) were committed across all 9 apps.

Field-meta only (Alter field on Meta options); no schema changes.
2026-04-30 09:47:44 +08:00
8faa68b615 feat(tenant): add Chinese verbose_name/help_text to tenant models (Phase 4.1 part 9/9) 2026-04-30 09:32:25 +08:00
e3b26cee05 feat(region): add Chinese verbose_name/help_text to region models (Phase 4.1 part 8/9) 2026-04-30 09:31:36 +08:00
289ec43265 feat(setting): add Chinese verbose_name/help_text to setting models (Phase 4.1 part 7/9) 2026-04-30 09:30:35 +08:00
9ef6eb6369 feat(permission): add Chinese verbose_name/help_text to permission models (Phase 4.1 part 6/9) 2026-04-30 09:29:21 +08:00
b57070f3b3 feat(account): add Chinese verbose_name and help_text to all account fields (Phase 4.1 part 5/9)
Sync DATA_MODEL_LOGIN.md field-level Chinese annotations to Django
models across 4 account tables (UserAccount, LoginAttempt,
PasswordResetToken, PasswordHistory).
2026-04-30 09:26:27 +08:00
f185127335 feat(org): add Chinese verbose_name and help_text to all org fields (Phase 4.1 part 4/9)
Sync DATA_MODEL_ORG.md field-level Chinese annotations to Django
models across 11 org tables (OrgUnit, Staff, StaffPersonalInfo,
StaffTransferLog, StaffRewardPunish, StaffAccount, StaffWorkExperience,
StaffEducation, StaffTraining, StaffFamilyMember, StaffRemark).
2026-04-30 09:25:17 +08:00
a3800bf09d feat(complex): add Chinese verbose_name and help_text to all complex fields (Phase 4.1 part 3/9)
Sync DATA_MODEL_COMPLEX.md field-level Chinese annotations to Django
models across 10 complex tables (Complex, ComplexAlias,
ComplexBusinessArea, ComplexSchool, ComplexMetroStation, Building,
RoomUnit, ComplexPhoto, ComplexAttachment, ComplexPriceTrend).
2026-04-30 09:22:08 +08:00
e67b07a7c8 feat(client): add Chinese verbose_name and help_text to all client fields (Phase 4.1 part 2/9)
Sync DATA_MODEL_CLIENT.md field-level Chinese annotations to Django
models across 11 client tables (Client, ClientContact, ClientRequirement,
ClientSchoolPreference, ClientFavoriteFolder, ClientFolderItem,
ClientFollowLog, ClientFollowLogAttachment, ClientViewing,
ClientPropertyMatch, ClientStatusLog).

Pre-existing docstrings retained on ClientFollowLog (partitioned parent
treated as unmanaged) and ClientStatusLog (immutable audit log).
2026-04-30 09:19:58 +08:00
3638fc0302 feat(property): add Chinese verbose_name and help_text to all property fields (Phase 4.1)
Sync DATA_MODEL_PROPERTY.md field-level Chinese annotations to Django
models across 23 property tables. Adds verbose_name= and help_text= to
every field in core.py, follow_keys.py, listings.py, media.py.

Pre-existing partitioned-table docstrings on FollowLog/PropertyPhoto
retained (signal Django ORM treats parent as unmanaged, RunSQL managed).
2026-04-30 09:15:43 +08:00
79c3cf2924 feat(models): add Chinese verbose_name to all 74 models (Phase 4.0)
为所有 Django 模型添加 Meta.verbose_name 和 verbose_name_plural(中文表名),
覆盖 10 个 app 的全部 74 个业务模型。

Phase 4.0 范围:
- 仅 Meta 类级别中文名(用于 Django Admin、drf-spectacular OpenAPI title、错误信息)
- 字段级 verbose_name= 和 help_text= 留待 Phase 4.1(待 PM 补全 DATA_MODEL 后同步)

变更:
- 20 个 models 文件改动(每个模型 +2 行)
- 8 个 0002/0003 迁移文件(Meta options 变更)
- apps/tenant/migrations/0001_initial.py(之前漏生成的 tenant 模型迁移)

manage.py check: 0 issues。
2026-04-29 19:10:38 +08:00
94d160223d feat: complete Phase 3 scaffolding (templates, static, Docker, per-app skeletons)
- Per-app skeleton completion: property/client/setting now have services/,
  tasks.py, views.py, urls.py, serializers.py, templates/<app>/, tests/
- admin.py added to all 10 apps (per spec §2.108 / §17.3)
- Top-level templates/: base.html, layouts/{app,auth}.html, components/
  {topbar,sidebar,pagination,toast,modal,empty-state}.html, errors/
  {403,404,500}.html
- static/: css/main.css (Tailwind entry), js/main.js (HTMX toast +
  CSRF wiring per §7.4), vendor/.gitkeep
- tailwind.config.js: Primary teal + neutral slate + semantic colors,
  Inter font stack, z-60/z-70, shadow-xs, slide-in-right animation per
  UI_SYSTEM §2.7/§10.1
- package.json: tailwindcss-only build/watch
- Dockerfile + docker-compose.yml (6 services: web/db/redis/celery/
  celery-beat/tailwind) + docker-compose.prod.yml + Makefile
- tests/ root: conftest.py with TenantClient fixture per §720,
  integration/{property,client,release}/, e2e/, schemathesis skeleton
- Removed empty apps/tenant/models/ (tenant uses models.py per §17.1)

Validated: manage.py check passes; tree matches spec §2 exactly.
2026-04-29 17:45:22 +08:00
Sisyphus
ed40de4050 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.
2026-04-29 17:33:58 +08:00
5b55dda367 feat(property): add 23-table property module with partitioned follow_logs and property_photos 2026-04-29 17:27:15 +08:00
c57462f6d1 feat(complex): add apps.complex with 10 models and full-text search
- 10 models: Complex, ComplexAlias, ComplexBusinessArea, ComplexSchool, ComplexMetroStation, Building, RoomUnit, ComplexPhoto, ComplexAttachment, ComplexPriceTrend
- RunSQL migration: pg_trgm extension, gin_trgm_ops indexes, tsvector triggers for complex search_vector (from name/alias/address)
- Optimistic locking via version field on Complex
- 4 lock flags (lock_building/room/info/standard_room) per spec
- Adds ComplexPropertyUsageType + ComplexBuildingStructure enums
- manage.py check passes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-29 17:19:01 +08:00
123 changed files with 11991 additions and 186 deletions

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements/base.txt requirements/base.txt
RUN pip install --no-cache-dir \
--proxy http://host.docker.internal:10808 \
--timeout 120 \
-r requirements/base.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "config.asgi:application", "--host", "0.0.0.0", "--port", "8000"]

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
.PHONY: dev migrate shell createsuperuser test lint tailwind-build
dev:
docker compose up
migrate:
docker compose exec web python manage.py migrate_schemas --shared
docker compose exec web python manage.py migrate_schemas
shell:
docker compose exec web python manage.py shell_plus
test:
docker compose exec web pytest apps/ -v
lint:
ruff check . && black --check .
tailwind-build:
npm run build
createsuperuser:
docker compose exec web python manage.py create_tenant_superuser

0
apps/account/admin.py Normal file
View File

View File

@@ -0,0 +1,29 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0002_initial'),
]
operations = [
migrations.AlterModelOptions(
name='loginattempt',
options={'verbose_name': '登录尝试记录', 'verbose_name_plural': '登录尝试记录'},
),
migrations.AlterModelOptions(
name='passwordhistory',
options={'ordering': ['-created_at'], 'verbose_name': '历史密码', 'verbose_name_plural': '历史密码'},
),
migrations.AlterModelOptions(
name='passwordresettoken',
options={'verbose_name': '密码重置令牌', 'verbose_name_plural': '密码重置令牌'},
),
migrations.AlterModelOptions(
name='useraccount',
options={'verbose_name': '用户账号', 'verbose_name_plural': '用户账号'},
),
]

View File

@@ -0,0 +1,146 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('org', '0003_alter_orgunit_address_city_and_more'),
('account', '0003_alter_loginattempt_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='loginattempt',
name='attempted_at',
field=models.DateTimeField(auto_now_add=True, help_text='分区键,按月分区', verbose_name='尝试时间'),
),
migrations.AlterField(
model_name='loginattempt',
name='failure_reason',
field=models.CharField(blank=True, choices=[('wrong_password', '用户名或密码错误'), ('wrong_captcha', '验证码错误'), ('account_locked', '账号锁定'), ('account_disabled', '账号停用'), ('tenant_not_found', '租户不存在')], help_text='wrong_password=密码错误 / wrong_captcha=验证码失败 / account_locked=账号锁定 / account_disabled=账号停用 / tenant_not_found=租户不存在', max_length=30, null=True, verbose_name='失败原因'),
),
migrations.AlterField(
model_name='loginattempt',
name='ip_address',
field=models.GenericIPAddressField(help_text='支持 IPv4/IPv6', verbose_name='来源 IP'),
),
migrations.AlterField(
model_name='loginattempt',
name='success',
field=models.BooleanField(verbose_name='是否登录成功'),
),
migrations.AlterField(
model_name='loginattempt',
name='user_agent',
field=models.TextField(blank=True, help_text='Electron 版本信息', null=True, verbose_name='客户端 UA'),
),
migrations.AlterField(
model_name='loginattempt',
name='username',
field=models.CharField(help_text='冗余存储,即使账号不存在也记录', max_length=30, verbose_name='登录用户名'),
),
migrations.AlterField(
model_name='passwordhistory',
name='created_at',
field=models.DateTimeField(auto_now_add=True, help_text='密码修改时间', verbose_name='记录时间'),
),
migrations.AlterField(
model_name='passwordhistory',
name='password_hash',
field=models.CharField(help_text='PBKDF2+SHA256 哈希值', max_length=128, verbose_name='密码哈希'),
),
migrations.AlterField(
model_name='passwordhistory',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_histories', to=settings.AUTH_USER_MODEL, verbose_name='关联账号'),
),
migrations.AlterField(
model_name='passwordresettoken',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='passwordresettoken',
name='expires_at',
field=models.DateTimeField(help_text='created_at + 30 分钟', verbose_name='过期时间'),
),
migrations.AlterField(
model_name='passwordresettoken',
name='is_used',
field=models.BooleanField(default=False, help_text='使用后立即置 True防止重放攻击', verbose_name='是否已使用'),
),
migrations.AlterField(
model_name='passwordresettoken',
name='token',
field=models.CharField(help_text='secrets.token_urlsafe(64) 生成86 字符),全局唯一', max_length=86, unique=True, verbose_name='令牌'),
),
migrations.AlterField(
model_name='passwordresettoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reset_tokens', to=settings.AUTH_USER_MODEL, verbose_name='关联账号'),
),
migrations.AlterField(
model_name='useraccount',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='useraccount',
name='created_by',
field=models.ForeignKey(blank=True, help_text='普通员工由 Tenant Admin 创建Tenant Admin 由平台运营创建(可为 NULL', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_accounts', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='useraccount',
name='email',
field=models.EmailField(blank=True, help_text='用于找回密码/用户名;为空则无法自助找回;同租户唯一', max_length=254, null=True, verbose_name='绑定邮箱'),
),
migrations.AlterField(
model_name='useraccount',
name='is_initial_password',
field=models.BooleanField(default=True, help_text='True 时登录成功后强制跳转修改密码页,不可跳过', verbose_name='是否初始密码'),
),
migrations.AlterField(
model_name='useraccount',
name='is_tenant_admin',
field=models.BooleanField(default=False, help_text='每个租户最多 1 个(应用层约束)', verbose_name='是否租户超管'),
),
migrations.AlterField(
model_name='useraccount',
name='locked_until',
field=models.DateTimeField(blank=True, help_text='到期后应用层将 status 恢复 active', null=True, verbose_name='锁定到期时间'),
),
migrations.AlterField(
model_name='useraccount',
name='phone_enc',
field=models.TextField(blank=True, help_text='AES-256-GCM 加密密文;普通员工必填', null=True, verbose_name='手机号(加密)'),
),
migrations.AlterField(
model_name='useraccount',
name='phone_hash',
field=models.CharField(blank=True, help_text='SHA-256 哈希;用于唯一性校验和查询;不可反推原文', max_length=64, null=True, verbose_name='手机号哈希'),
),
migrations.AlterField(
model_name='useraccount',
name='staff',
field=models.OneToOneField(blank=True, help_text='员工档案绑定1:1普通员工必须有值Tenant Admin 可为空', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='account', to='org.staff', verbose_name='员工档案'),
),
migrations.AlterField(
model_name='useraccount',
name='status',
field=models.CharField(choices=[('active', '启用'), ('disabled', '停用'), ('locked', '锁定')], default='active', help_text='active=正常 / disabled=停用 / locked=锁定30 分钟自动恢复)', max_length=10, verbose_name='账号状态'),
),
migrations.AlterField(
model_name='useraccount',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='最后更新时间'),
),
migrations.AlterField(
model_name='useraccount',
name='username',
field=models.CharField(help_text='普通员工=手机号(11位数字) / Tenant Admin=自定义(字母开头6~30位);创建后不可更改', max_length=30, verbose_name='登录名'),
),
]

View File

@@ -17,37 +17,78 @@ class UserAccountManager(BaseUserManager):
class UserAccount(AbstractBaseUser):
username = models.CharField(max_length=30)
email = models.EmailField(null=True, blank=True)
username = models.CharField(
max_length=30,
verbose_name="登录名",
help_text="普通员工=手机号(11位数字) / Tenant Admin=自定义(字母开头6~30位);创建后不可更改",
)
email = models.EmailField(
null=True,
blank=True,
verbose_name="绑定邮箱",
help_text="用于找回密码/用户名;为空则无法自助找回;同租户唯一",
)
phone_enc = models.TextField(
null=True,
blank=True,
help_text="AES-256-GCM ciphertext of phone (core.encryption.PhoneEncryption).",
verbose_name="手机号(加密)",
help_text="AES-256-GCM 加密密文;普通员工必填",
)
phone_hash = models.CharField(
max_length=64,
null=True,
blank=True,
verbose_name="手机号哈希",
help_text="SHA-256 哈希;用于唯一性校验和查询;不可反推原文",
)
phone_hash = models.CharField(max_length=64, null=True, blank=True)
staff = models.OneToOneField(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="account",
verbose_name="员工档案",
help_text="员工档案绑定1:1普通员工必须有值Tenant Admin 可为空",
)
is_tenant_admin = models.BooleanField(
default=False,
verbose_name="是否租户超管",
help_text="每个租户最多 1 个(应用层约束)",
)
is_tenant_admin = models.BooleanField(default=False)
status = models.CharField(
max_length=10,
choices=UserAccountStatus.choices,
default=UserAccountStatus.ACTIVE,
verbose_name="账号状态",
help_text="active=正常 / disabled=停用 / locked=锁定30 分钟自动恢复)",
)
is_initial_password = models.BooleanField(
default=True,
verbose_name="是否初始密码",
help_text="True 时登录成功后强制跳转修改密码页,不可跳过",
)
locked_until = models.DateTimeField(
null=True,
blank=True,
verbose_name="锁定到期时间",
help_text="到期后应用层将 status 恢复 active",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="最后更新时间",
)
is_initial_password = models.BooleanField(default=True)
locked_until = 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(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_accounts",
verbose_name="创建人",
help_text="普通员工由 Tenant Admin 创建Tenant Admin 由平台运营创建(可为 NULL",
)
USERNAME_FIELD = "username"
@@ -57,6 +98,8 @@ class UserAccount(AbstractBaseUser):
class Meta:
db_table = "user_accounts"
verbose_name = "用户账号"
verbose_name_plural = "用户账号"
constraints = [
models.UniqueConstraint(fields=["username"], name="uq_user_accounts_username"),
models.UniqueConstraint(
@@ -88,20 +131,42 @@ class UserAccount(AbstractBaseUser):
class LoginAttempt(models.Model):
username = models.CharField(max_length=30)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(null=True, blank=True)
success = models.BooleanField()
username = models.CharField(
max_length=30,
verbose_name="登录用户名",
help_text="冗余存储,即使账号不存在也记录",
)
ip_address = models.GenericIPAddressField(
verbose_name="来源 IP",
help_text="支持 IPv4/IPv6",
)
user_agent = models.TextField(
null=True,
blank=True,
verbose_name="客户端 UA",
help_text="Electron 版本信息",
)
success = models.BooleanField(
verbose_name="是否登录成功",
)
failure_reason = models.CharField(
max_length=30,
null=True,
blank=True,
choices=LoginFailureReason.choices,
verbose_name="失败原因",
help_text="wrong_password=密码错误 / wrong_captcha=验证码失败 / account_locked=账号锁定 / account_disabled=账号停用 / tenant_not_found=租户不存在",
)
attempted_at = models.DateTimeField(
auto_now_add=True,
verbose_name="尝试时间",
help_text="分区键,按月分区",
)
attempted_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "login_attempts"
verbose_name = "登录尝试记录"
verbose_name_plural = "登录尝试记录"
indexes = [
models.Index(fields=["username"], name="idx_login_attempts_username"),
models.Index(fields=["ip_address"], name="idx_login_attempts_ip"),
@@ -118,14 +183,32 @@ class PasswordResetToken(models.Model):
"account.UserAccount",
on_delete=models.CASCADE,
related_name="reset_tokens",
verbose_name="关联账号",
)
token = models.CharField(
max_length=86,
unique=True,
verbose_name="令牌",
help_text="secrets.token_urlsafe(64) 生成86 字符),全局唯一",
)
expires_at = models.DateTimeField(
verbose_name="过期时间",
help_text="created_at + 30 分钟",
)
is_used = models.BooleanField(
default=False,
verbose_name="是否已使用",
help_text="使用后立即置 True防止重放攻击",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
token = models.CharField(max_length=86, unique=True)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "password_reset_tokens"
verbose_name = "密码重置令牌"
verbose_name_plural = "密码重置令牌"
indexes = [
models.Index(fields=["user"], name="idx_pw_reset_tokens_user"),
]
@@ -139,12 +222,23 @@ class PasswordHistory(models.Model):
"account.UserAccount",
on_delete=models.CASCADE,
related_name="password_histories",
verbose_name="关联账号",
)
password_hash = models.CharField(
max_length=128,
verbose_name="密码哈希",
help_text="PBKDF2+SHA256 哈希值",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="记录时间",
help_text="密码修改时间",
)
password_hash = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "password_histories"
verbose_name = "历史密码"
verbose_name_plural = "历史密码"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "-created_at"], name="idx_pw_histories_user"),

0
apps/client/admin.py Normal file
View File

View File

@@ -0,0 +1,351 @@
# Generated by Django 4.2.16 on 2026-04-29 09:31
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('org', '0001_initial'),
('fonrey_property', '0002_partitions_and_triggers'),
]
operations = [
migrations.CreateModel(
name='ClientFollowLog',
fields=[
('id', models.UUIDField(primary_key=True, serialize=False)),
('created_at', models.DateTimeField()),
('log_type', models.CharField(choices=[('written', '写入跟进'), ('modified', '修改跟进'), ('sensitive_view', '敏感查看'), ('other', '其他'), ('system', '系统')], max_length=30)),
('purpose', models.CharField(blank=True, default='', max_length=50)),
('content', models.TextField(blank=True, default='')),
('log_tag', models.CharField(blank=True, default='', max_length=50)),
('change_detail', models.JSONField(blank=True, null=True)),
('is_public', models.BooleanField(default=True)),
('is_deletable', models.BooleanField(default=True)),
('operator_snapshot', models.JSONField(blank=True, null=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
],
options={
'db_table': 'client_follow_logs',
'managed': False,
},
),
migrations.CreateModel(
name='Client',
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)),
('client_no', models.CharField(max_length=30, unique=True)),
('client_type', models.CharField(choices=[('private', '私客'), ('public', '公客'), ('transacted', '成交客')], default='private', max_length=20)),
('status', models.CharField(choices=[('buying', '求购'), ('renting', '求租'), ('buy_or_rent', '租购'), ('suspended', '暂缓'), ('bought', '已购'), ('rented_done', '已租'), ('public', '公客'), ('invalid', '无效')], default='buying', max_length=20)),
('grade', models.CharField(choices=[('A', 'A急迫'), ('B', 'B较强'), ('C', 'C一般'), ('D', 'D较弱'), ('E', 'E暂不关注')], default='C', max_length=5)),
('property_usage', models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('shop', '商铺'), ('office', '写字楼'), ('other', '其他')], default='residential', max_length=30)),
('buying_purpose', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rigid', '刚需'), ('investment', '投资'), ('school_district', '学区'), ('upgrade', '改善'), ('commercial', '商用'), ('other', '其他')], max_length=20), blank=True, default=list, size=None)),
('payment_method', models.CharField(blank=True, choices=[('full', '全额'), ('mortgage', '商业贷款'), ('mortgage_fund', '商贷+公积金'), ('fund', '公积金')], default='', max_length=30)),
('properties_owned', models.CharField(blank=True, choices=[('none', ''), ('local_none', '本地无/外地有'), ('local_has', '本地有')], default='', max_length=20)),
('has_loan_record', models.BooleanField(blank=True, null=True)),
('id_type', models.CharField(blank=True, choices=[('id_card', '身份证'), ('passport', '护照'), ('hk_macao', '港澳通行证'), ('other', '其他')], default='', max_length=20)),
('id_number_enc', models.BinaryField(blank=True, null=True)),
('source', models.CharField(blank=True, default='', max_length=50)),
('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(blank=True, null=True)),
('transfer_to_public_type', models.CharField(blank=True, choices=[('manual', '手动转公'), ('auto', '自动转公'), ('marketing_jump', '营销客跳公'), ('resource_public', '资料客素公')], default='', max_length=20)),
('transferred_public_at', models.DateTimeField(blank=True, null=True)),
('invalid_reason', models.CharField(blank=True, choices=[('invalid_phone', '号码无效'), ('peer_agent', '同行'), ('ad', '广告推销'), ('no_intent', '无意向'), ('other', '其他')], default='', max_length=30)),
('invalidated_at', models.DateTimeField(blank=True, null=True)),
('transacted_at', models.DateField(blank=True, null=True)),
('transacted_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('transacted_type', models.CharField(blank=True, choices=[('bought', '我购'), ('rented', '我租')], default='', max_length=20)),
('transacted_property_type', models.CharField(blank=True, choices=[('second_hand', '二手'), ('new_house', '新房')], default='', max_length=20)),
('activity_level', models.CharField(blank=True, choices=[('new_matched', '新配对'), ('active_7d', '7日活跃'), ('active_30d', '30日活跃'), ('active_90d', '90日活跃'), ('expiring', '即将过期'), ('frozen', '暂缓中'), ('invalid', '无效')], default='', max_length=20)),
('last_active_at', models.DateTimeField(blank=True, null=True)),
('last_follow_at', models.DateTimeField(blank=True, null=True)),
('commission_date', models.DateField(blank=True, null=True)),
('entrust_count', models.SmallIntegerField(default=1)),
('version', models.IntegerField(default=1)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created', to='org.staff')),
('first_recorder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='first_recorded_clients', to='org.staff')),
('org_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clients', to='org.orgunit')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_clients', to='org.staff')),
('transacted_property', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transacted_clients', to='fonrey_property.property')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated', to='org.staff')),
],
options={
'db_table': 'clients',
},
),
migrations.CreateModel(
name='ClientFavoriteFolder',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('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(blank=True, null=True)),
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_folders', to='org.staff')),
],
options={
'db_table': 'client_favorite_folders',
},
),
migrations.CreateModel(
name='ClientRequirement',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('requirement_type', models.CharField(choices=[('second_hand', '二手'), ('new_house', '新房'), ('rental', '租房')], max_length=20)),
('is_primary', models.BooleanField(default=True)),
('budget_min', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('budget_max', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('area_min', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('area_max', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('bedroom_counts', django.contrib.postgres.fields.ArrayField(base_field=models.SmallIntegerField(), blank=True, default=list, size=None)),
('floor_preferences', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('no_first', '不要一楼'), ('low', '低楼层'), ('mid', '中楼层'), ('high', '高楼层'), ('no_top', '不要顶楼')], max_length=20), blank=True, default=list, size=None)),
('orientations', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('east', ''), ('south', ''), ('west', '西'), ('north', '')], max_length=10), blank=True, default=list, size=None)),
('decorations', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rough', '毛坯'), ('plain', '清水'), ('simple', '简装'), ('medium', '中装'), ('fine', '精装'), ('luxury', '豪装')], max_length=10), blank=True, default=list, size=None)),
('building_age_ranges', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('within_5y', '5年内'), ('5_10y', '5-10年'), ('10_15y', '10-15年'), ('15_20y', '15-20年'), ('over_20y', '20年以上')], max_length=20), blank=True, default=list, size=None)),
('intent_district_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('intent_business_area_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('intent_complex_names', models.TextField(blank=True, default='')),
('transportation', models.CharField(blank=True, default='', max_length=50)),
('intent_school_names', models.TextField(blank=True, default='')),
('school_enrollment_date', models.DateField(blank=True, null=True)),
('traffic_preference', models.TextField(blank=True, default='')),
('requirement_notes', models.CharField(blank=True, default='', max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requirements', to='fonrey_client.client')),
],
options={
'db_table': 'client_requirements',
},
),
migrations.CreateModel(
name='ClientPropertyMatch',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('match_source', models.CharField(choices=[('recorded', '录客配房'), ('system', '系统配房')], default='recorded', max_length=20)),
('match_group', models.CharField(blank=True, choices=[('quality_layout', '优质户型'), ('price_reduced', '降价'), ('hot', '热门'), ('newly_listed', '新上')], default='', max_length=30)),
('match_score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('match_reasons', models.JSONField(blank=True, null=True)),
('status', models.CharField(choices=[('suggested', '待推送'), ('shared', '已分享'), ('rejected', '已反馈不合适'), ('viewed', '客户已查看')], default='suggested', max_length=20)),
('shared_at', models.DateTimeField(blank=True, null=True)),
('feedback', models.CharField(blank=True, default='', max_length=50)),
('calculated_at', models.DateTimeField(auto_now_add=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='property_matches', to='fonrey_client.client')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_matches', to='org.staff')),
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='client_matches', to='fonrey_property.property')),
],
options={
'db_table': 'client_property_matches',
},
),
migrations.CreateModel(
name='ClientFollowLogAttachment',
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, default='', max_length=10)),
('has_location', models.BooleanField(default=False)),
('sort_order', models.SmallIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'client_follow_log_attachments',
'indexes': [models.Index(fields=['follow_log_id'], name='idx_cfla_log')],
},
),
migrations.CreateModel(
name='ClientFolderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('added_at', models.DateTimeField(auto_now_add=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_items', to='fonrey_client.client')),
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fonrey_client.clientfavoritefolder')),
],
options={
'db_table': 'client_folder_items',
},
),
migrations.CreateModel(
name='ClientContact',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('sort_order', models.SmallIntegerField(default=0)),
('name', models.CharField(max_length=50)),
('gender', models.CharField(choices=[('male', '先生'), ('female', '女士')], default='male', max_length=10)),
('phone_enc', models.BinaryField()),
('phone_hash', models.CharField(max_length=64)),
('phone_country_code', models.CharField(default='+86', max_length=10)),
('phone_is_invalid', models.BooleanField(default=False)),
('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.CharField(blank=True, default='', max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='fonrey_client.client')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_client_contacts', to='org.staff')),
],
options={
'db_table': 'client_contacts',
},
),
migrations.CreateModel(
name='ClientViewing',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('viewing_type', models.CharField(choices=[('appointment', '预约'), ('viewing', '带看'), ('revisit', '复看'), ('empty', '空看')], default='viewing', max_length=20)),
('companion_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('cooperator_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, size=None)),
('scheduled_at', models.DateTimeField(blank=True, null=True)),
('viewing_start_at', models.DateTimeField(blank=True, null=True)),
('viewing_end_at', models.DateTimeField(blank=True, null=True)),
('situation', models.TextField(blank=True, default='')),
('client_intent', models.CharField(blank=True, choices=[('interested', '感兴趣'), ('not_interested', '不感兴趣'), ('negotiating', '谈判中'), ('cancelled', '取消')], default='', max_length=20)),
('viewing_progress', models.SmallIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('agent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_viewings', to='org.staff')),
('client', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='viewings', to='fonrey_client.client')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_client_viewings', to='org.staff')),
('property', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='client_viewings', to='fonrey_property.property')),
],
options={
'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')],
},
),
migrations.CreateModel(
name='ClientStatusLog',
fields=[
('id', models.UUIDField(primary_key=True, serialize=False)),
('change_type', models.CharField(choices=[('status_change', '改状态'), ('grade_change', '改等级'), ('to_public', '转公客'), ('to_transacted', '转成交'), ('to_invalid', '转无效'), ('owner_change', '改归属人'), ('source_change', '改来源'), ('merge', '合并客源')], max_length=30)),
('old_value', models.JSONField(blank=True, null=True)),
('new_value', models.JSONField(blank=True, null=True)),
('reason', models.TextField(blank=True, default='')),
('operated_at', models.DateTimeField(auto_now_add=True)),
('client', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='status_logs', to='fonrey_client.client')),
('operator', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='client_status_changes', to='org.staff')),
],
options={
'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')],
},
),
migrations.CreateModel(
name='ClientSchoolPreference',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('school_id', models.UUIDField(blank=True, null=True)),
('school_name', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('requirement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='school_preferences', to='fonrey_client.clientrequirement')),
],
options={
'db_table': 'client_school_preferences',
'indexes': [models.Index(fields=['requirement'], name='idx_csp_requirement')],
},
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['client'], name='idx_creq_client'),
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['requirement_type', 'client'], name='idx_creq_type'),
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['budget_min', 'budget_max'], name='idx_creq_budget'),
),
migrations.AddIndex(
model_name='clientrequirement',
index=models.Index(fields=['area_min', 'area_max'], name='idx_creq_area'),
),
migrations.AddIndex(
model_name='clientpropertymatch',
index=models.Index(fields=['client', 'match_source', 'match_group'], name='idx_cpm_client_grp'),
),
migrations.AddIndex(
model_name='clientpropertymatch',
index=models.Index(fields=['client', 'status'], name='idx_cpm_status'),
),
migrations.AddConstraint(
model_name='clientpropertymatch',
constraint=models.UniqueConstraint(fields=('client', 'property'), name='uq_client_match_pair'),
),
migrations.AddIndex(
model_name='clientfolderitem',
index=models.Index(fields=['client'], name='idx_cfi_client'),
),
migrations.AddConstraint(
model_name='clientfolderitem',
constraint=models.UniqueConstraint(fields=('folder', 'client'), name='uq_cfi_folder_client'),
),
migrations.AddIndex(
model_name='clientfavoritefolder',
index=models.Index(fields=['staff'], name='idx_cff_staff'),
),
migrations.AddConstraint(
model_name='clientfavoritefolder',
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True), ('is_default', True)), fields=('staff',), name='uq_cff_default_per_staff'),
),
migrations.AddIndex(
model_name='clientcontact',
index=models.Index(fields=['phone_hash'], name='idx_cc_phone_hash'),
),
migrations.AddIndex(
model_name='clientcontact',
index=models.Index(fields=['phone2_hash'], name='idx_cc_phone2_hash'),
),
migrations.AddIndex(
model_name='clientcontact',
index=models.Index(fields=['client'], name='idx_cc_client'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['client_type', 'status'], name='idx_clients_type_stat'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['owner'], name='idx_clients_owner'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['org_unit'], name='idx_clients_org_unit'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['activity_level', '-last_active_at'], name='idx_clients_activity'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['grade'], name='idx_clients_grade'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['-transferred_public_at'], name='idx_clients_transferred'),
),
migrations.AddIndex(
model_name='client',
index=models.Index(fields=['-last_follow_at'], name='idx_clients_last_follow'),
),
]

View File

@@ -0,0 +1,99 @@
from django.db import migrations
CREATE_CLIENT_FOLLOW_LOGS = """
CREATE TABLE client_follow_logs (
id UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
log_type VARCHAR(30) NOT NULL
CHECK (log_type IN ('written','modified','sensitive_view',
'other','system')),
purpose VARCHAR(50),
content TEXT,
log_tag VARCHAR(50),
change_detail JSONB,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
is_deletable BOOLEAN NOT NULL DEFAULT TRUE,
operator_id UUID REFERENCES staff(id) ON DELETE SET NULL,
operator_snapshot JSONB,
deleted_at TIMESTAMPTZ,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE client_follow_logs_2026_04 PARTITION OF client_follow_logs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE client_follow_logs_2026_05 PARTITION OF client_follow_logs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE client_follow_logs_default PARTITION OF client_follow_logs DEFAULT;
CREATE INDEX idx_cfl_client_time ON client_follow_logs(client_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_cfl_type ON client_follow_logs(client_id, log_type, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_cfl_operator ON client_follow_logs(operator_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_cfl_sensitive ON client_follow_logs(client_id, created_at DESC)
WHERE log_type = 'sensitive_view';
"""
DROP_CLIENT_FOLLOW_LOGS = "DROP TABLE IF EXISTS client_follow_logs CASCADE;"
CREATE_TRIGGERS = """
CREATE OR REPLACE FUNCTION update_client_last_follow()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.log_type = 'written' THEN
UPDATE clients
SET last_follow_at = NEW.created_at,
last_active_at = NEW.created_at,
updated_at = NOW()
WHERE id = NEW.client_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_client_last_follow
AFTER INSERT ON client_follow_logs
FOR EACH ROW EXECUTE FUNCTION update_client_last_follow();
CREATE OR REPLACE FUNCTION update_client_viewing_progress()
RETURNS TRIGGER AS $$
BEGIN
UPDATE clients
SET updated_at = NOW()
WHERE id = NEW.client_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_client_viewing_progress
AFTER INSERT ON client_viewings
FOR EACH ROW EXECUTE FUNCTION update_client_viewing_progress();
"""
DROP_TRIGGERS = """
DROP TRIGGER IF EXISTS trg_client_viewing_progress ON client_viewings;
DROP FUNCTION IF EXISTS update_client_viewing_progress();
DROP TRIGGER IF EXISTS trg_client_last_follow ON client_follow_logs;
DROP FUNCTION IF EXISTS update_client_last_follow();
"""
CREATE_UNIQUE_CLIENT_NO = """
CREATE UNIQUE INDEX idx_clients_client_no_active ON clients(client_no)
WHERE deleted_at IS NULL;
"""
DROP_UNIQUE_CLIENT_NO = "DROP INDEX IF EXISTS idx_clients_client_no_active;"
class Migration(migrations.Migration):
dependencies = [
("fonrey_client", "0001_initial"),
]
operations = [
migrations.RunSQL(CREATE_CLIENT_FOLLOW_LOGS, reverse_sql=DROP_CLIENT_FOLLOW_LOGS),
migrations.RunSQL(CREATE_TRIGGERS, reverse_sql=DROP_TRIGGERS),
migrations.RunSQL(CREATE_UNIQUE_CLIENT_NO, reverse_sql=DROP_UNIQUE_CLIENT_NO),
]

View File

@@ -0,0 +1,57 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fonrey_client', '0002_partitions_and_triggers'),
]
operations = [
migrations.AlterModelOptions(
name='client',
options={'verbose_name': '客源', 'verbose_name_plural': '客源'},
),
migrations.AlterModelOptions(
name='clientcontact',
options={'verbose_name': '客源联系人', 'verbose_name_plural': '客源联系人'},
),
migrations.AlterModelOptions(
name='clientfavoritefolder',
options={'verbose_name': '私客收藏夹', 'verbose_name_plural': '私客收藏夹'},
),
migrations.AlterModelOptions(
name='clientfolderitem',
options={'verbose_name': '收藏夹中的客源', 'verbose_name_plural': '收藏夹中的客源'},
),
migrations.AlterModelOptions(
name='clientfollowlog',
options={'managed': False, 'verbose_name': '客源跟进日志', 'verbose_name_plural': '客源跟进日志'},
),
migrations.AlterModelOptions(
name='clientfollowlogattachment',
options={'verbose_name': '客源跟进附件', 'verbose_name_plural': '客源跟进附件'},
),
migrations.AlterModelOptions(
name='clientpropertymatch',
options={'verbose_name': '智能配房', 'verbose_name_plural': '智能配房'},
),
migrations.AlterModelOptions(
name='clientrequirement',
options={'verbose_name': '客源需求', 'verbose_name_plural': '客源需求'},
),
migrations.AlterModelOptions(
name='clientschoolpreference',
options={'verbose_name': '意向学校', 'verbose_name_plural': '意向学校'},
),
migrations.AlterModelOptions(
name='clientstatuslog',
options={'verbose_name': '客源状态变更日志', 'verbose_name_plural': '客源状态变更日志'},
),
migrations.AlterModelOptions(
name='clientviewing',
options={'verbose_name': '带看记录', 'verbose_name_plural': '带看记录'},
),
]

View File

@@ -0,0 +1,667 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('org', '0003_alter_orgunit_address_city_and_more'),
('fonrey_property', '0004_alter_commission_agent_and_more'),
('fonrey_client', '0003_alter_client_options_alter_clientcontact_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='client',
name='activity_level',
field=models.CharField(blank=True, choices=[('new_matched', '新配对'), ('active_7d', '7日活跃'), ('active_30d', '30日活跃'), ('active_90d', '90日活跃'), ('expiring', '即将过期'), ('frozen', '暂缓中'), ('invalid', '无效')], default='', help_text='new_matched=新配偶 / active_7d / active_30d / active_90d / expiring / frozen / invalid异步计算', max_length=20, verbose_name='活跃度'),
),
migrations.AlterField(
model_name='client',
name='buying_purpose',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rigid', '刚需'), ('investment', '投资'), ('school_district', '学区'), ('upgrade', '改善'), ('commercial', '商用'), ('other', '其他')], max_length=20), blank=True, default=list, help_text='多选rigid=刚需 / investment=投资 / school_district=学区 / upgrade=改善 / commercial=商用 / other=其他', size=None, verbose_name='购房目的'),
),
migrations.AlterField(
model_name='client',
name='client_no',
field=models.CharField(help_text='系统生成的客源编号,格式由运营配置(如 KY20260424001', max_length=30, unique=True, verbose_name='客源编号'),
),
migrations.AlterField(
model_name='client',
name='client_type',
field=models.CharField(choices=[('private', '私客'), ('public', '公客'), ('transacted', '成交客')], default='private', help_text='private=私客 / public=公客 / transacted=成交客', max_length=20, verbose_name='客源分类'),
),
migrations.AlterField(
model_name='client',
name='commission_date',
field=models.DateField(blank=True, null=True, verbose_name='委托日期'),
),
migrations.AlterField(
model_name='client',
name='entrust_count',
field=models.SmallIntegerField(default=1, help_text='成交后再委托则累加', verbose_name='委托次数'),
),
migrations.AlterField(
model_name='client',
name='first_recorder',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='first_recorded_clients', to='org.staff', verbose_name='首录人'),
),
migrations.AlterField(
model_name='client',
name='grade',
field=models.CharField(choices=[('A', 'A急迫'), ('B', 'B较强'), ('C', 'C一般'), ('D', 'D较弱'), ('E', 'E暂不关注')], default='C', help_text='A=A急迫 / B=较强 / C=一般 / D=较弱 / E=暂不关注', max_length=5, verbose_name='客源等级'),
),
migrations.AlterField(
model_name='client',
name='has_loan_record',
field=models.BooleanField(blank=True, null=True, verbose_name='有无贷款记录'),
),
migrations.AlterField(
model_name='client',
name='id_number_enc',
field=models.BinaryField(blank=True, help_text='AES 加密存储', null=True, verbose_name='证件号码(加密)'),
),
migrations.AlterField(
model_name='client',
name='id_type',
field=models.CharField(blank=True, choices=[('id_card', '身份证'), ('passport', '护照'), ('hk_macao', '港澳通行证'), ('other', '其他')], default='', help_text='id_card=身份证 / passport=护照 / hk_macao=港澳台 / other=其他', max_length=20, verbose_name='证件类型'),
),
migrations.AlterField(
model_name='client',
name='invalid_reason',
field=models.CharField(blank=True, choices=[('invalid_phone', '号码无效'), ('peer_agent', '同行'), ('ad', '广告推销'), ('no_intent', '无意向'), ('other', '其他')], default='', help_text='invalid_phone=号码无效 / peer_agent=同行 / ad=广告推销 / no_intent=无意向 / other=其他', max_length=30, verbose_name='无效原因'),
),
migrations.AlterField(
model_name='client',
name='invalidated_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='标记无效时间'),
),
migrations.AlterField(
model_name='client',
name='is_big_value',
field=models.BooleanField(default=False, help_text='影响筛选展示', verbose_name='是否大价值客户'),
),
migrations.AlterField(
model_name='client',
name='is_pinned',
field=models.BooleanField(default=False, help_text='列表顶部置顶', verbose_name='是否置顶'),
),
migrations.AlterField(
model_name='client',
name='is_protected',
field=models.BooleanField(default=False, help_text='影响转公逻辑', verbose_name='是否保护客'),
),
migrations.AlterField(
model_name='client',
name='is_starred',
field=models.BooleanField(default=False, help_text='快速标记,详细收藏夹用 client_folder_items', verbose_name='是否收藏'),
),
migrations.AlterField(
model_name='client',
name='last_active_at',
field=models.DateTimeField(blank=True, help_text='触发器维护', null=True, verbose_name='最后有效跟进时间'),
),
migrations.AlterField(
model_name='client',
name='last_follow_at',
field=models.DateTimeField(blank=True, help_text='冗余字段,列表排序用', null=True, verbose_name='最后跟进时间'),
),
migrations.AlterField(
model_name='client',
name='org_unit',
field=models.ForeignKey(blank=True, help_text='冗余字段,加速筛选', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clients', to='org.orgunit', verbose_name='归属部门'),
),
migrations.AlterField(
model_name='client',
name='owner',
field=models.ForeignKey(blank=True, help_text='私客独占跟进人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_clients', to='org.staff', verbose_name='归属人'),
),
migrations.AlterField(
model_name='client',
name='payment_method',
field=models.CharField(blank=True, choices=[('full', '全额'), ('mortgage', '商业贷款'), ('mortgage_fund', '商贷+公积金'), ('fund', '公积金')], default='', help_text='full=全额 / mortgage=商业贷款 / mortgage_fund=商贷+公积金 / fund=公积金', max_length=30, verbose_name='付款方式'),
),
migrations.AlterField(
model_name='client',
name='prefers_new_house',
field=models.BooleanField(blank=True, help_text='用于筛选', null=True, verbose_name='偏好新房'),
),
migrations.AlterField(
model_name='client',
name='properties_owned',
field=models.CharField(blank=True, choices=[('none', ''), ('local_none', '本地无/外地有'), ('local_has', '本地有')], default='', help_text='none=无 / local_none=本地无外地有 / local_has=本地有', max_length=20, verbose_name='名下房产'),
),
migrations.AlterField(
model_name='client',
name='property_usage',
field=models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('shop', '商铺'), ('office', '写字楼'), ('other', '其他')], default='residential', help_text='residential=住宅 / villa=别墅 / commercial_residential=商住 / shop=商铺 / office=写字楼 / other=其他', max_length=30, verbose_name='房屋用途'),
),
migrations.AlterField(
model_name='client',
name='remarks',
field=models.TextField(blank=True, default='', help_text='最多200字', verbose_name='备注'),
),
migrations.AlterField(
model_name='client',
name='source',
field=models.CharField(blank=True, default='', help_text='lookup_items 维护', max_length=50, verbose_name='客户来源'),
),
migrations.AlterField(
model_name='client',
name='status',
field=models.CharField(choices=[('buying', '求购'), ('renting', '求租'), ('buy_or_rent', '租购'), ('suspended', '暂缓'), ('bought', '已购'), ('rented_done', '已租'), ('public', '公客'), ('invalid', '无效')], default='buying', help_text='buying=求购 / renting=求租 / buy_or_rent=租购 / suspended=暂缓 / bought=已购 / rented_done=已租 / public=公客 / invalid=无效(详见 ENUMS', max_length=20, verbose_name='客源状态'),
),
migrations.AlterField(
model_name='client',
name='transacted_at',
field=models.DateField(blank=True, null=True, verbose_name='成交日期'),
),
migrations.AlterField(
model_name='client',
name='transacted_price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:万元', max_digits=12, null=True, verbose_name='成交价格'),
),
migrations.AlterField(
model_name='client',
name='transacted_property',
field=models.ForeignKey(blank=True, help_text='成交关联的房源', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transacted_clients', to='fonrey_property.property', verbose_name='成交房源'),
),
migrations.AlterField(
model_name='client',
name='transacted_property_type',
field=models.CharField(blank=True, choices=[('second_hand', '二手'), ('new_house', '新房')], default='', help_text='second_hand=二手 / new_house=新房', max_length=20, verbose_name='成交房源类型'),
),
migrations.AlterField(
model_name='client',
name='transacted_type',
field=models.CharField(blank=True, choices=[('bought', '我购'), ('rented', '我租')], default='', help_text='bought=我购 / rented=我租', max_length=20, verbose_name='成交类型'),
),
migrations.AlterField(
model_name='client',
name='transfer_to_public_type',
field=models.CharField(blank=True, choices=[('manual', '手动转公'), ('auto', '自动转公'), ('marketing_jump', '营销客跳公'), ('resource_public', '资料客素公')], default='', help_text='manual=手动转公 / auto=自动转公(超时) / marketing_jump=营销客跳公 / resource_public=资料客素公', max_length=20, verbose_name='转公客方式'),
),
migrations.AlterField(
model_name='client',
name='transferred_public_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='进入公客池时间'),
),
migrations.AlterField(
model_name='client',
name='version',
field=models.IntegerField(default=1, help_text='乐观锁;每次 UPDATE +1应用层检测 0 行受影响时抛 ConflictError', verbose_name='版本号'),
),
migrations.AlterField(
model_name='clientcontact',
name='client',
field=models.ForeignKey(help_text='联系人随客源级联删除', on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='fonrey_client.client', verbose_name='所属客源'),
),
migrations.AlterField(
model_name='clientcontact',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='clientcontact',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_client_contacts', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='clientcontact',
name='deleted_at',
field=models.DateTimeField(blank=True, help_text='软删除时间戳NULL=未删除(不影响客源本身)', null=True, verbose_name='删除时间'),
),
migrations.AlterField(
model_name='clientcontact',
name='gender',
field=models.CharField(choices=[('male', '先生'), ('female', '女士')], default='male', help_text='male=先生 / female=女士', max_length=10, verbose_name='性别'),
),
migrations.AlterField(
model_name='clientcontact',
name='name',
field=models.CharField(max_length=50, verbose_name='联系人姓名'),
),
migrations.AlterField(
model_name='clientcontact',
name='phone2_enc',
field=models.BinaryField(blank=True, null=True, verbose_name='备用电话2加密'),
),
migrations.AlterField(
model_name='clientcontact',
name='phone2_hash',
field=models.CharField(blank=True, default='', help_text='SHA-256用于重复检测', max_length=64, verbose_name='备用电话2哈希'),
),
migrations.AlterField(
model_name='clientcontact',
name='phone_country_code',
field=models.CharField(default='+86', max_length=10, verbose_name='国际区号'),
),
migrations.AlterField(
model_name='clientcontact',
name='phone_enc',
field=models.BinaryField(help_text='AES-256-GCM 加密手机号电话1', verbose_name='手机号(加密)'),
),
migrations.AlterField(
model_name='clientcontact',
name='phone_hash',
field=models.CharField(help_text='SHA-256 哈希(重复检测)', max_length=64, verbose_name='手机号哈希'),
),
migrations.AlterField(
model_name='clientcontact',
name='phone_is_invalid',
field=models.BooleanField(default=False, help_text='标记无效后该号码不再参与重复检测', verbose_name='号码是否无效'),
),
migrations.AlterField(
model_name='clientcontact',
name='qq',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='QQ号'),
),
migrations.AlterField(
model_name='clientcontact',
name='remarks',
field=models.CharField(blank=True, default='', help_text='最多200字', max_length=200, verbose_name='联系人备注'),
),
migrations.AlterField(
model_name='clientcontact',
name='sort_order',
field=models.SmallIntegerField(default=0, help_text='sort_order=0 为主联系人,姓名用于客源姓名显示', verbose_name='排序顺序'),
),
migrations.AlterField(
model_name='clientcontact',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='最后更新时间'),
),
migrations.AlterField(
model_name='clientcontact',
name='wechat',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='微信号'),
),
migrations.AlterField(
model_name='clientfavoritefolder',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='clientfavoritefolder',
name='deleted_at',
field=models.DateTimeField(blank=True, help_text='软删除时间戳NULL=未删除', null=True, verbose_name='删除时间'),
),
migrations.AlterField(
model_name='clientfavoritefolder',
name='is_default',
field=models.BooleanField(default=False, help_text='系统默认收藏夹,每个经纪人只能有一个', verbose_name='是否默认'),
),
migrations.AlterField(
model_name='clientfavoritefolder',
name='name',
field=models.CharField(help_text='最多10字', max_length=10, verbose_name='收藏夹名称'),
),
migrations.AlterField(
model_name='clientfavoritefolder',
name='sort_order',
field=models.IntegerField(default=0, help_text='升序排列', verbose_name='显示顺序'),
),
migrations.AlterField(
model_name='clientfavoritefolder',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_folders', to='org.staff', verbose_name='所属经纪人'),
),
migrations.AlterField(
model_name='clientfolderitem',
name='added_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='加入收藏夹时间'),
),
migrations.AlterField(
model_name='clientfolderitem',
name='client',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_items', to='fonrey_client.client', verbose_name='被收藏的客源'),
),
migrations.AlterField(
model_name='clientfolderitem',
name='folder',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fonrey_client.clientfavoritefolder', verbose_name='所属收藏夹'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='file_key',
field=models.TextField(help_text='R2/S3 存储路径', verbose_name='文件存储路径'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='file_name',
field=models.CharField(help_text='原始文件名(用于展示和下载)', max_length=255, verbose_name='文件名'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='file_size',
field=models.IntegerField(help_text='单位bytes最大 20MB', verbose_name='文件大小'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='file_type',
field=models.CharField(blank=True, default='', help_text='bmp / jpg / png / gif', max_length=10, verbose_name='文件类型'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='follow_log_id',
field=models.UUIDField(help_text='跨分区 FK不通过 Django FK 强制约束', verbose_name='所属跟进日志ID'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='has_location',
field=models.BooleanField(default=False, help_text='是否含 GPS 位置信息', verbose_name='是否含位置信息'),
),
migrations.AlterField(
model_name='clientfollowlogattachment',
name='sort_order',
field=models.SmallIntegerField(default=0, verbose_name='排序顺序'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='calculated_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='配房计算时间'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='client',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='property_matches', to='fonrey_client.client', verbose_name='所属客源'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='created_by',
field=models.ForeignKey(blank=True, help_text='触发配房操作的员工录客配房时记录系统配房可为NULL', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_matches', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='feedback',
field=models.CharField(blank=True, default='', help_text='lookup_items 维护', max_length=50, verbose_name='反馈原因'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='match_group',
field=models.CharField(blank=True, choices=[('quality_layout', '优质户型'), ('price_reduced', '降价'), ('hot', '热门'), ('newly_listed', '新上')], default='', help_text='quality_layout=优质户型 / price_reduced=降价 / hot=热门 / newly_listed=新上', max_length=30, verbose_name='匹配分组'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='match_reasons',
field=models.JSONField(blank=True, help_text='格式:[{"key": "budget", "match": true}, ...]', null=True, verbose_name='匹配原因详情'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='match_score',
field=models.DecimalField(blank=True, decimal_places=2, help_text='0-100', max_digits=5, null=True, verbose_name='匹配度评分'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='match_source',
field=models.CharField(choices=[('recorded', '录客配房'), ('system', '系统配房')], default='recorded', help_text='recorded=录客配房(基于录入需求) / system=系统配房(算法推荐)', max_length=20, verbose_name='匹配来源'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='property',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='client_matches', to='fonrey_property.property', verbose_name='匹配房源'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='shared_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='分享时间'),
),
migrations.AlterField(
model_name='clientpropertymatch',
name='status',
field=models.CharField(choices=[('suggested', '待推送'), ('shared', '已分享'), ('rejected', '已反馈不合适'), ('viewed', '客户已查看')], default='suggested', help_text='suggested=待推送 / shared=已分享 / rejected=已反馈不合适 / viewed=客户已查看', max_length=20, verbose_name='状态'),
),
migrations.AlterField(
model_name='clientrequirement',
name='area_max',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:㎡', max_digits=8, null=True, verbose_name='最大面积'),
),
migrations.AlterField(
model_name='clientrequirement',
name='area_min',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:㎡', max_digits=8, null=True, verbose_name='最小面积'),
),
migrations.AlterField(
model_name='clientrequirement',
name='bedroom_counts',
field=django.contrib.postgres.fields.ArrayField(base_field=models.SmallIntegerField(), blank=True, default=list, help_text='多选,如 [2,3]', size=None, verbose_name='可接受卧室数'),
),
migrations.AlterField(
model_name='clientrequirement',
name='budget_max',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='最高预算'),
),
migrations.AlterField(
model_name='clientrequirement',
name='budget_min',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:万元/元,依据需求类型', max_digits=12, null=True, verbose_name='最低预算'),
),
migrations.AlterField(
model_name='clientrequirement',
name='building_age_ranges',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('within_5y', '5年内'), ('5_10y', '5-10年'), ('10_15y', '10-15年'), ('15_20y', '15-20年'), ('over_20y', '20年以上')], max_length=20), blank=True, default=list, help_text='多选within_5y / 5_10y / 10_15y / 15_20y / over_20y', size=None, verbose_name='楼龄偏好'),
),
migrations.AlterField(
model_name='clientrequirement',
name='client',
field=models.ForeignKey(help_text='需求随客源级联删除', on_delete=django.db.models.deletion.CASCADE, related_name='requirements', to='fonrey_client.client', verbose_name='所属客源'),
),
migrations.AlterField(
model_name='clientrequirement',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='clientrequirement',
name='decorations',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rough', '毛坯'), ('plain', '清水'), ('simple', '简装'), ('medium', '中装'), ('fine', '精装'), ('luxury', '豪装')], max_length=10), blank=True, default=list, help_text='多选(枚举同 properties.decoration', size=None, verbose_name='装修偏好'),
),
migrations.AlterField(
model_name='clientrequirement',
name='floor_preferences',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('no_first', '不要一楼'), ('low', '低楼层'), ('mid', '中楼层'), ('high', '高楼层'), ('no_top', '不要顶楼')], max_length=20), blank=True, default=list, help_text='多选no_first=不要一层 / low=低楼层 / mid=中楼层 / high=高楼层 / no_top=不要顶层', size=None, verbose_name='楼层偏好'),
),
migrations.AlterField(
model_name='clientrequirement',
name='intent_business_area_ids',
field=django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, help_text='商圈 ID 数组', size=None, verbose_name='意向商圈'),
),
migrations.AlterField(
model_name='clientrequirement',
name='intent_complex_names',
field=models.TextField(blank=True, default='', help_text='文本逗号分隔最多500字', verbose_name='意向小区'),
),
migrations.AlterField(
model_name='clientrequirement',
name='intent_district_ids',
field=django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, help_text='行政区 ID 数组', size=None, verbose_name='意向行政区'),
),
migrations.AlterField(
model_name='clientrequirement',
name='intent_school_names',
field=models.TextField(blank=True, default='', help_text='文本,逗号分隔', verbose_name='意向学校'),
),
migrations.AlterField(
model_name='clientrequirement',
name='is_primary',
field=models.BooleanField(default=True, help_text='用于列表展示', verbose_name='是否主需求'),
),
migrations.AlterField(
model_name='clientrequirement',
name='orientations',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('east', ''), ('south', ''), ('west', '西'), ('north', '')], max_length=10), blank=True, default=list, help_text='多选east=东 / south=南 / west=西 / north=北', size=None, verbose_name='朝向偏好'),
),
migrations.AlterField(
model_name='clientrequirement',
name='requirement_notes',
field=models.CharField(blank=True, default='', help_text='最多200字', max_length=200, verbose_name='需求备注'),
),
migrations.AlterField(
model_name='clientrequirement',
name='requirement_type',
field=models.CharField(choices=[('second_hand', '二手'), ('new_house', '新房'), ('rental', '租房')], help_text='second_hand=二手 / new_house=新房 / rental=租房', max_length=20, verbose_name='需求类型'),
),
migrations.AlterField(
model_name='clientrequirement',
name='school_enrollment_date',
field=models.DateField(blank=True, help_text='月份精度取该月1日存储', null=True, verbose_name='入学时间'),
),
migrations.AlterField(
model_name='clientrequirement',
name='traffic_preference',
field=models.TextField(blank=True, default='', verbose_name='交通备注'),
),
migrations.AlterField(
model_name='clientrequirement',
name='transportation',
field=models.CharField(blank=True, default='', help_text='最多50字', max_length=50, verbose_name='交通要求'),
),
migrations.AlterField(
model_name='clientrequirement',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='最后更新时间'),
),
migrations.AlterField(
model_name='clientschoolpreference',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='clientschoolpreference',
name='requirement',
field=models.ForeignKey(help_text='意向学校随需求级联删除', on_delete=django.db.models.deletion.CASCADE, related_name='school_preferences', to='fonrey_client.clientrequirement', verbose_name='所属需求'),
),
migrations.AlterField(
model_name='clientschoolpreference',
name='school_id',
field=models.UUIDField(blank=True, help_text='从学校表选择,允许为 NULL自由输入', null=True, verbose_name='学校ID'),
),
migrations.AlterField(
model_name='clientschoolpreference',
name='school_name',
field=models.CharField(help_text='当 school_id 为 NULL 时为手动输入', max_length=100, verbose_name='学校名称'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='change_type',
field=models.CharField(choices=[('status_change', '改状态'), ('grade_change', '改等级'), ('to_public', '转公客'), ('to_transacted', '转成交'), ('to_invalid', '转无效'), ('owner_change', '改归属人'), ('source_change', '改来源'), ('merge', '合并客源')], help_text='status_change=改状态 / grade_change=改等级 / to_public=转公客 / to_transacted=转成交 / to_invalid=转无效 / owner_change=改归属人 / source_change=改来源', max_length=30, verbose_name='变更类型'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='client',
field=models.ForeignKey(help_text='状态日志永久保留RESTRICT 防止删除客源', on_delete=django.db.models.deletion.RESTRICT, related_name='status_logs', to='fonrey_client.client', verbose_name='所属客源'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='id',
field=models.UUIDField(primary_key=True, serialize=False, verbose_name='主键'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='new_value',
field=models.JSONField(blank=True, null=True, verbose_name='变更后快照'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='old_value',
field=models.JSONField(blank=True, help_text='格式:{"status": "buying", "label": "求购"}', null=True, verbose_name='变更前快照'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='operated_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='操作时间'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='operator',
field=models.ForeignKey(help_text='必填,状态变更审计用', on_delete=django.db.models.deletion.RESTRICT, related_name='client_status_changes', to='org.staff', verbose_name='操作人'),
),
migrations.AlterField(
model_name='clientstatuslog',
name='reason',
field=models.TextField(blank=True, default='', help_text='改状态必填最多200字', verbose_name='变更理由'),
),
migrations.AlterField(
model_name='clientviewing',
name='agent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_viewings', to='org.staff', verbose_name='主带看经纪人'),
),
migrations.AlterField(
model_name='clientviewing',
name='client',
field=models.ForeignKey(help_text='带看记录仅软删除,不随客源删除', on_delete=django.db.models.deletion.RESTRICT, related_name='viewings', to='fonrey_client.client', verbose_name='所属客源'),
),
migrations.AlterField(
model_name='clientviewing',
name='client_intent',
field=models.CharField(blank=True, choices=[('interested', '感兴趣'), ('not_interested', '不感兴趣'), ('negotiating', '谈判中'), ('cancelled', '取消')], default='', help_text='interested=感兴趣 / not_interested=不感兴趣 / negotiating=谈判中 / cancelled=取消', max_length=20, verbose_name='客户意向'),
),
migrations.AlterField(
model_name='clientviewing',
name='companion_ids',
field=django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, help_text='员工 ID 数组最多5人', size=None, verbose_name='陪看人员'),
),
migrations.AlterField(
model_name='clientviewing',
name='cooperator_ids',
field=django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), blank=True, default=list, help_text='员工 ID 数组最多5人', size=None, verbose_name='合作带看人'),
),
migrations.AlterField(
model_name='clientviewing',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='clientviewing',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_client_viewings', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='clientviewing',
name='deleted_at',
field=models.DateTimeField(blank=True, help_text='软删除时间戳;带看记录可软删除', null=True, verbose_name='删除时间'),
),
migrations.AlterField(
model_name='clientviewing',
name='property',
field=models.ForeignKey(help_text='房源删除时保留带看记录', on_delete=django.db.models.deletion.RESTRICT, related_name='client_viewings', to='fonrey_property.property', verbose_name='带看房源'),
),
migrations.AlterField(
model_name='clientviewing',
name='scheduled_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='预约时间'),
),
migrations.AlterField(
model_name='clientviewing',
name='situation',
field=models.TextField(blank=True, default='', help_text='必填≥6字', verbose_name='带看情况'),
),
migrations.AlterField(
model_name='clientviewing',
name='viewing_end_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='带看结束时间'),
),
migrations.AlterField(
model_name='clientviewing',
name='viewing_progress',
field=models.SmallIntegerField(blank=True, help_text='1=一看2=二看…,冗余字段,触发器维护', null=True, verbose_name='带看进度'),
),
migrations.AlterField(
model_name='clientviewing',
name='viewing_start_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='实际带看开始时间'),
),
migrations.AlterField(
model_name='clientviewing',
name='viewing_type',
field=models.CharField(choices=[('appointment', '预约'), ('viewing', '带看'), ('revisit', '复看'), ('empty', '空看')], default='viewing', help_text='appointment=预约 / viewing=带看 / revisit=复看 / empty=空看', max_length=20, verbose_name='带看类型'),
),
]

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,318 @@
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",
verbose_name="所属客源",
help_text="联系人随客源级联删除",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序顺序",
help_text="sort_order=0 为主联系人,姓名用于客源姓名显示",
)
name = models.CharField(
max_length=50,
verbose_name="联系人姓名",
)
gender = models.CharField(
max_length=10,
choices=ClientContactGender.choices,
default=ClientContactGender.MALE,
verbose_name="性别",
help_text="male=先生 / female=女士",
)
phone_enc = models.BinaryField(
verbose_name="手机号(加密)",
help_text="AES-256-GCM 加密手机号电话1",
)
phone_hash = models.CharField(
max_length=64,
verbose_name="手机号哈希",
help_text="SHA-256 哈希(重复检测)",
)
phone_country_code = models.CharField(
max_length=10,
default="+86",
verbose_name="国际区号",
)
phone_is_invalid = models.BooleanField(
default=False,
verbose_name="号码是否无效",
help_text="标记无效后该号码不再参与重复检测",
)
phone2_enc = models.BinaryField(
null=True,
blank=True,
verbose_name="备用电话2加密",
)
phone2_hash = models.CharField(
max_length=64,
blank=True,
default="",
verbose_name="备用电话2哈希",
help_text="SHA-256用于重复检测",
)
wechat = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="微信号",
)
qq = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="QQ号",
)
remarks = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="联系人备注",
help_text="最多200字",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="最后更新时间",
)
deleted_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="删除时间",
help_text="软删除时间戳NULL=未删除(不影响客源本身)",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_client_contacts",
verbose_name="创建人",
)
class Meta:
db_table = "client_contacts"
verbose_name = "客源联系人"
verbose_name_plural = "客源联系人"
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",
verbose_name="所属客源",
help_text="需求随客源级联删除",
)
requirement_type = models.CharField(
max_length=20,
choices=ClientRequirementType.choices,
verbose_name="需求类型",
help_text="second_hand=二手 / new_house=新房 / rental=租房",
)
is_primary = models.BooleanField(
default=True,
verbose_name="是否主需求",
help_text="用于列表展示",
)
budget_min = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="最低预算",
help_text="单位:万元/元,依据需求类型",
)
budget_max = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="最高预算",
)
area_min = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
verbose_name="最小面积",
help_text="单位:㎡",
)
area_max = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
verbose_name="最大面积",
help_text="单位:㎡",
)
bedroom_counts = ArrayField(
models.SmallIntegerField(),
blank=True,
default=list,
verbose_name="可接受卧室数",
help_text="多选,如 [2,3]",
)
floor_preferences = ArrayField(
models.CharField(max_length=20, choices=ClientFloorPreference.choices),
blank=True,
default=list,
verbose_name="楼层偏好",
help_text="多选no_first=不要一层 / low=低楼层 / mid=中楼层 / high=高楼层 / no_top=不要顶层",
)
orientations = ArrayField(
models.CharField(max_length=10, choices=ClientOrientation.choices),
blank=True,
default=list,
verbose_name="朝向偏好",
help_text="多选east=东 / south=南 / west=西 / north=北",
)
decorations = ArrayField(
models.CharField(max_length=10, choices=ClientDecoration.choices),
blank=True,
default=list,
verbose_name="装修偏好",
help_text="多选(枚举同 properties.decoration",
)
building_age_ranges = ArrayField(
models.CharField(max_length=20, choices=ClientBuildingAgeRange.choices),
blank=True,
default=list,
verbose_name="楼龄偏好",
help_text="多选within_5y / 5_10y / 10_15y / 15_20y / over_20y",
)
intent_district_ids = ArrayField(
models.UUIDField(),
blank=True,
default=list,
verbose_name="意向行政区",
help_text="行政区 ID 数组",
)
intent_business_area_ids = ArrayField(
models.UUIDField(),
blank=True,
default=list,
verbose_name="意向商圈",
help_text="商圈 ID 数组",
)
intent_complex_names = models.TextField(
blank=True,
default="",
verbose_name="意向小区",
help_text="文本逗号分隔最多500字",
)
transportation = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="交通要求",
help_text="最多50字",
)
intent_school_names = models.TextField(
blank=True,
default="",
verbose_name="意向学校",
help_text="文本,逗号分隔",
)
school_enrollment_date = models.DateField(
null=True,
blank=True,
verbose_name="入学时间",
help_text="月份精度取该月1日存储",
)
traffic_preference = models.TextField(
blank=True,
default="",
verbose_name="交通备注",
)
requirement_notes = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="需求备注",
help_text="最多200字",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="最后更新时间",
)
class Meta:
db_table = "client_requirements"
verbose_name = "客源需求"
verbose_name_plural = "客源需求"
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",
verbose_name="所属需求",
help_text="意向学校随需求级联删除",
)
school_id = models.UUIDField(
null=True,
blank=True,
verbose_name="学校ID",
help_text="从学校表选择,允许为 NULL自由输入",
)
school_name = models.CharField(
max_length=100,
verbose_name="学校名称",
help_text="当 school_id 为 NULL 时为手动输入",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
class Meta:
db_table = "client_school_preferences"
verbose_name = "意向学校"
verbose_name_plural = "意向学校"
indexes = [
models.Index(fields=["requirement"], name="idx_csp_requirement"),
]

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

@@ -0,0 +1,291 @@
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,
verbose_name="客源编号",
help_text="系统生成的客源编号,格式由运营配置(如 KY20260424001",
)
client_type = models.CharField(
max_length=20,
choices=ClientType.choices,
default=ClientType.PRIVATE,
verbose_name="客源分类",
help_text="private=私客 / public=公客 / transacted=成交客",
)
status = models.CharField(
max_length=20,
choices=ClientStatus.choices,
default=ClientStatus.BUYING,
verbose_name="客源状态",
help_text="buying=求购 / renting=求租 / buy_or_rent=租购 / suspended=暂缓 / bought=已购 / rented_done=已租 / public=公客 / invalid=无效(详见 ENUMS",
)
grade = models.CharField(
max_length=5,
choices=ClientGrade.choices,
default=ClientGrade.C,
verbose_name="客源等级",
help_text="A=A急迫 / B=较强 / C=一般 / D=较弱 / E=暂不关注",
)
property_usage = models.CharField(
max_length=30,
choices=ClientPropertyUsage.choices,
default=ClientPropertyUsage.RESIDENTIAL,
verbose_name="房屋用途",
help_text="residential=住宅 / villa=别墅 / commercial_residential=商住 / shop=商铺 / office=写字楼 / other=其他",
)
buying_purpose = ArrayField(
models.CharField(max_length=20, choices=ClientBuyingPurpose.choices),
blank=True,
default=list,
verbose_name="购房目的",
help_text="多选rigid=刚需 / investment=投资 / school_district=学区 / upgrade=改善 / commercial=商用 / other=其他",
)
payment_method = models.CharField(
max_length=30,
choices=ClientPaymentMethod.choices,
blank=True,
default="",
verbose_name="付款方式",
help_text="full=全额 / mortgage=商业贷款 / mortgage_fund=商贷+公积金 / fund=公积金",
)
properties_owned = models.CharField(
max_length=20,
choices=ClientPropertiesOwned.choices,
blank=True,
default="",
verbose_name="名下房产",
help_text="none=无 / local_none=本地无外地有 / local_has=本地有",
)
has_loan_record = models.BooleanField(
null=True,
blank=True,
verbose_name="有无贷款记录",
)
id_type = models.CharField(
max_length=20,
choices=ClientIdType.choices,
blank=True,
default="",
verbose_name="证件类型",
help_text="id_card=身份证 / passport=护照 / hk_macao=港澳台 / other=其他",
)
id_number_enc = models.BinaryField(
null=True,
blank=True,
verbose_name="证件号码(加密)",
help_text="AES 加密存储",
)
source = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="客户来源",
help_text="lookup_items 维护",
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="备注",
help_text="最多200字",
)
is_starred = models.BooleanField(
default=False,
verbose_name="是否收藏",
help_text="快速标记,详细收藏夹用 client_folder_items",
)
is_pinned = models.BooleanField(
default=False,
verbose_name="是否置顶",
help_text="列表顶部置顶",
)
is_big_value = models.BooleanField(
default=False,
verbose_name="是否大价值客户",
help_text="影响筛选展示",
)
is_protected = models.BooleanField(
default=False,
verbose_name="是否保护客",
help_text="影响转公逻辑",
)
prefers_new_house = models.BooleanField(
null=True,
blank=True,
verbose_name="偏好新房",
help_text="用于筛选",
)
transfer_to_public_type = models.CharField(
max_length=20,
choices=ClientTransferToPublicType.choices,
blank=True,
default="",
verbose_name="转公客方式",
help_text="manual=手动转公 / auto=自动转公(超时) / marketing_jump=营销客跳公 / resource_public=资料客素公",
)
transferred_public_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="进入公客池时间",
)
invalid_reason = models.CharField(
max_length=30,
choices=ClientInvalidReason.choices,
blank=True,
default="",
verbose_name="无效原因",
help_text="invalid_phone=号码无效 / peer_agent=同行 / ad=广告推销 / no_intent=无意向 / other=其他",
)
invalidated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="标记无效时间",
)
transacted_at = models.DateField(
null=True,
blank=True,
verbose_name="成交日期",
)
transacted_property = models.ForeignKey(
"fonrey_property.Property",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="transacted_clients",
verbose_name="成交房源",
help_text="成交关联的房源",
)
transacted_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="成交价格",
help_text="单位:万元",
)
transacted_type = models.CharField(
max_length=20,
choices=ClientTransactedType.choices,
blank=True,
default="",
verbose_name="成交类型",
help_text="bought=我购 / rented=我租",
)
transacted_property_type = models.CharField(
max_length=20,
choices=ClientTransactedPropertyType.choices,
blank=True,
default="",
verbose_name="成交房源类型",
help_text="second_hand=二手 / new_house=新房",
)
first_recorder = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="first_recorded_clients",
verbose_name="首录人",
)
owner = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="owned_clients",
verbose_name="归属人",
help_text="私客独占跟进人",
)
org_unit = models.ForeignKey(
"org.OrgUnit",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="clients",
verbose_name="归属部门",
help_text="冗余字段,加速筛选",
)
activity_level = models.CharField(
max_length=20,
choices=ClientActivityLevel.choices,
blank=True,
default="",
verbose_name="活跃度",
help_text="new_matched=新配偶 / active_7d / active_30d / active_90d / expiring / frozen / invalid异步计算",
)
last_active_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="最后有效跟进时间",
help_text="触发器维护",
)
last_follow_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="最后跟进时间",
help_text="冗余字段,列表排序用",
)
commission_date = models.DateField(
null=True,
blank=True,
verbose_name="委托日期",
)
entrust_count = models.SmallIntegerField(
default=1,
verbose_name="委托次数",
help_text="成交后再委托则累加",
)
version = models.IntegerField(
default=1,
verbose_name="版本号",
help_text="乐观锁;每次 UPDATE +1应用层检测 0 行受影响时抛 ConflictError",
)
class Meta:
db_table = "clients"
verbose_name = "客源"
verbose_name_plural = "客源"
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,84 @@
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",
verbose_name="所属经纪人",
)
name = models.CharField(
max_length=10,
verbose_name="收藏夹名称",
help_text="最多10字",
)
is_default = models.BooleanField(
default=False,
verbose_name="是否默认",
help_text="系统默认收藏夹,每个经纪人只能有一个",
)
sort_order = models.IntegerField(
default=0,
verbose_name="显示顺序",
help_text="升序排列",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
deleted_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="删除时间",
help_text="软删除时间戳NULL=未删除",
)
class Meta:
db_table = "client_favorite_folders"
verbose_name = "私客收藏夹"
verbose_name_plural = "私客收藏夹"
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",
verbose_name="所属收藏夹",
)
client = models.ForeignKey(
"fonrey_client.Client",
on_delete=models.CASCADE,
related_name="folder_items",
verbose_name="被收藏的客源",
)
added_at = models.DateTimeField(
auto_now_add=True,
verbose_name="加入收藏夹时间",
)
class Meta:
db_table = "client_folder_items"
verbose_name = "收藏夹中的客源"
verbose_name_plural = "收藏夹中的客源"
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,145 @@
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,
verbose_name="主键",
)
created_at = models.DateTimeField(
verbose_name="创建时间",
help_text="分区键",
)
client = models.ForeignKey(
"fonrey_client.Client",
on_delete=models.CASCADE,
related_name="follow_logs",
verbose_name="所属客源",
help_text="跟进日志随客源级联删除",
)
log_type = models.CharField(
max_length=30,
choices=ClientFollowLogType.choices,
verbose_name="跟进类型",
help_text="written=写入跟进 / modified=修改跟进 / sensitive_view=敏感信息查看(不可删) / other=其他跟进 / system=系统日志",
)
purpose = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="跟进目的",
help_text="lookup_items 维护23项",
)
content = models.TextField(
blank=True,
default="",
verbose_name="跟进内容",
help_text="最少6字最多500字",
)
log_tag = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="跟进标签",
help_text="has_recording=有录音 / has_photo=有图片 / not_satisfied=对房源不满意 / still_considering=还在考虑 / ready_to_deposit=可交定金",
)
change_detail = models.JSONField(
null=True,
blank=True,
verbose_name="字段变更明细",
help_text='修改跟进专用,格式:{"field": "grade", "old": "C", "new": "B", "label": "等级"}',
)
is_public = models.BooleanField(
default=True,
verbose_name="是否公开",
help_text="FALSE=仅本人及上级可见",
)
is_deletable = models.BooleanField(
default=True,
verbose_name="是否可删除",
help_text="敏感信息查看类型为 FALSE不可删除",
)
operator = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="操作人",
)
operator_snapshot = models.JSONField(
null=True,
blank=True,
verbose_name="操作人快照",
help_text="{name, store_group, role}(防止人员调动后显示异常)",
)
deleted_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="删除时间",
help_text="仅 is_deletable=TRUE 时可软删",
)
class Meta:
db_table = "client_follow_logs"
verbose_name = "客源跟进日志"
verbose_name_plural = "客源跟进日志"
managed = False
unique_together = (("id", "created_at"),)
class ClientFollowLogAttachment(UUIDPrimaryKeyModel):
follow_log_id = models.UUIDField(
verbose_name="所属跟进日志ID",
help_text="跨分区 FK不通过 Django FK 强制约束",
)
file_key = models.TextField(
verbose_name="文件存储路径",
help_text="R2/S3 存储路径",
)
file_name = models.CharField(
max_length=255,
verbose_name="文件名",
help_text="原始文件名(用于展示和下载)",
)
file_size = models.IntegerField(
verbose_name="文件大小",
help_text="单位bytes最大 20MB",
)
file_type = models.CharField(
max_length=10,
blank=True,
default="",
verbose_name="文件类型",
help_text="bmp / jpg / png / gif",
)
has_location = models.BooleanField(
default=False,
verbose_name="是否含位置信息",
help_text="是否含 GPS 位置信息",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序顺序",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
class Meta:
db_table = "client_follow_log_attachments"
verbose_name = "客源跟进附件"
verbose_name_plural = "客源跟进附件"
indexes = [models.Index(fields=["follow_log_id"], name="idx_cfla_log")]

View File

@@ -0,0 +1,281 @@
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",
verbose_name="所属客源",
help_text="带看记录仅软删除,不随客源删除",
)
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.RESTRICT,
related_name="client_viewings",
verbose_name="带看房源",
help_text="房源删除时保留带看记录",
)
viewing_type = models.CharField(
max_length=20,
choices=ClientViewingType.choices,
default=ClientViewingType.VIEWING,
verbose_name="带看类型",
help_text="appointment=预约 / viewing=带看 / revisit=复看 / empty=空看",
)
agent = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="led_viewings",
verbose_name="主带看经纪人",
)
companion_ids = ArrayField(
models.UUIDField(),
blank=True,
default=list,
verbose_name="陪看人员",
help_text="员工 ID 数组最多5人",
)
cooperator_ids = ArrayField(
models.UUIDField(),
blank=True,
default=list,
verbose_name="合作带看人",
help_text="员工 ID 数组最多5人",
)
scheduled_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="预约时间",
)
viewing_start_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="实际带看开始时间",
)
viewing_end_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="带看结束时间",
)
situation = models.TextField(
blank=True,
default="",
verbose_name="带看情况",
help_text="必填≥6字",
)
client_intent = models.CharField(
max_length=20,
choices=ClientViewingIntent.choices,
blank=True,
default="",
verbose_name="客户意向",
help_text="interested=感兴趣 / not_interested=不感兴趣 / negotiating=谈判中 / cancelled=取消",
)
viewing_progress = models.SmallIntegerField(
null=True,
blank=True,
verbose_name="带看进度",
help_text="1=一看2=二看…,冗余字段,触发器维护",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
deleted_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="删除时间",
help_text="软删除时间戳;带看记录可软删除",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_client_viewings",
verbose_name="创建人",
)
class Meta:
db_table = "client_viewings"
verbose_name = "带看记录"
verbose_name_plural = "带看记录"
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",
verbose_name="所属客源",
)
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.CASCADE,
related_name="client_matches",
verbose_name="匹配房源",
)
match_source = models.CharField(
max_length=20,
choices=ClientPropertyMatchSource.choices,
default=ClientPropertyMatchSource.RECORDED,
verbose_name="匹配来源",
help_text="recorded=录客配房(基于录入需求) / system=系统配房(算法推荐)",
)
match_group = models.CharField(
max_length=30,
choices=ClientPropertyMatchGroup.choices,
blank=True,
default="",
verbose_name="匹配分组",
help_text="quality_layout=优质户型 / price_reduced=降价 / hot=热门 / newly_listed=新上",
)
match_score = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
verbose_name="匹配度评分",
help_text="0-100",
)
match_reasons = models.JSONField(
null=True,
blank=True,
verbose_name="匹配原因详情",
help_text='格式:[{"key": "budget", "match": true}, ...]',
)
status = models.CharField(
max_length=20,
choices=ClientPropertyMatchStatus.choices,
default=ClientPropertyMatchStatus.SUGGESTED,
verbose_name="状态",
help_text="suggested=待推送 / shared=已分享 / rejected=已反馈不合适 / viewed=客户已查看",
)
shared_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="分享时间",
)
feedback = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="反馈原因",
help_text="lookup_items 维护",
)
calculated_at = models.DateTimeField(
auto_now_add=True,
verbose_name="配房计算时间",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_matches",
verbose_name="创建人",
help_text="触发配房操作的员工录客配房时记录系统配房可为NULL",
)
class Meta:
db_table = "client_property_matches"
verbose_name = "智能配房"
verbose_name_plural = "智能配房"
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,
verbose_name="主键",
)
client = models.ForeignKey(
"fonrey_client.Client",
on_delete=models.RESTRICT,
related_name="status_logs",
verbose_name="所属客源",
help_text="状态日志永久保留RESTRICT 防止删除客源",
)
change_type = models.CharField(
max_length=30,
choices=ClientStatusLogChangeType.choices,
verbose_name="变更类型",
help_text="status_change=改状态 / grade_change=改等级 / to_public=转公客 / to_transacted=转成交 / to_invalid=转无效 / owner_change=改归属人 / source_change=改来源",
)
old_value = models.JSONField(
null=True,
blank=True,
verbose_name="变更前快照",
help_text='格式:{"status": "buying", "label": "求购"}',
)
new_value = models.JSONField(
null=True,
blank=True,
verbose_name="变更后快照",
)
reason = models.TextField(
blank=True,
default="",
verbose_name="变更理由",
help_text="改状态必填最多200字",
)
operator = models.ForeignKey(
"org.Staff",
on_delete=models.RESTRICT,
related_name="client_status_changes",
verbose_name="操作人",
help_text="必填,状态变更审计用",
)
operated_at = models.DateTimeField(
auto_now_add=True,
verbose_name="操作时间",
)
class Meta:
db_table = "client_status_logs"
verbose_name = "客源状态变更日志"
verbose_name_plural = "客源状态变更日志"
indexes = [
models.Index(fields=["client", "-operated_at"], name="idx_csl_client"),
models.Index(fields=["change_type", "-operated_at"], name="idx_csl_type"),
]

View File

View File

0
apps/client/tasks.py Normal file
View File

View File

View File

5
apps/client/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path
app_name = "client"
urlpatterns: list = []

0
apps/client/views.py Normal file
View File

0
apps/complex/admin.py Normal file
View File

View File

@@ -0,0 +1,332 @@
# Generated by Django 4.2.16 on 2026-04-29 09:12
import django.contrib.postgres.fields
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 = [
('region', '0001_initial'),
('org', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Complex',
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(help_text='标准楼盘名称,不可在编辑页直接修改', max_length=200)),
('address', models.CharField(blank=True, default='', max_length=500)),
('address_summary', models.CharField(blank=True, default='', max_length=100)),
('latitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)),
('longitude', models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True)),
('property_usage_types', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('commercial', '商业'), ('office', '写字楼'), ('other', '其他')], max_length=30), blank=True, default=list, size=None)),
('building_structure', models.CharField(blank=True, choices=[('unit_room', '单元-房号'), ('other', '其他')], default='', max_length=30)),
('building_type', models.CharField(blank=True, choices=[('slab', '板楼'), ('tower', '塔楼'), ('slab_tower', '板塔结合')], default='', max_length=20)),
('land_use_years', models.CharField(blank=True, default='', max_length=30)),
('built_year', models.SmallIntegerField(blank=True, null=True)),
('built_years', django.contrib.postgres.fields.ArrayField(base_field=models.SmallIntegerField(), blank=True, default=list, size=None)),
('ownership_category', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, default=list, size=None)),
('total_units', models.IntegerField(blank=True, null=True)),
('total_households', models.IntegerField(blank=True, null=True)),
('total_floor_area', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('plot_area', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('plot_ratio', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('green_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('developer', models.CharField(blank=True, default='', max_length=200)),
('property_company', models.CharField(blank=True, default='', max_length=200)),
('property_fee', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('property_phone', models.CharField(blank=True, default='', max_length=30)),
('parking_total', models.IntegerField(blank=True, null=True)),
('parking_underground', models.IntegerField(blank=True, null=True)),
('parking_ratio', models.CharField(blank=True, default='', max_length=20)),
('water_type', models.CharField(blank=True, choices=[('civil', '民水'), ('commercial', '商水')], default='', max_length=10)),
('electricity_type', models.CharField(blank=True, choices=[('civil', '民电'), ('commercial', '商电')], default='', max_length=10)),
('has_central_heating', models.BooleanField(blank=True, null=True)),
('has_gas', models.BooleanField(blank=True, null=True)),
('remarks', models.TextField(blank=True, default='')),
('lock_building', models.BooleanField(default=False)),
('lock_room', models.BooleanField(default=False)),
('lock_info', models.BooleanField(default=False)),
('lock_standard_room', models.BooleanField(default=False)),
('search_vector', django.contrib.postgres.search.SearchVectorField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('version', models.IntegerField(default=1, help_text='乐观锁版本号UPDATE 时 +1')),
],
options={
'db_table': 'complexes',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ComplexSchool',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('zone_type', models.CharField(blank=True, choices=[('guaranteed', '对口'), ('reference', '参考'), ('lottery', '摇号')], default='', max_length=30)),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_schools', to='fonrey_complex.complex')),
('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.school')),
],
options={
'db_table': 'complex_schools',
},
),
migrations.CreateModel(
name='ComplexPriceTrend',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('record_month', models.DateField(help_text='月份统一存为该月1日')),
('avg_sale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('avg_unit_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('transaction_count', models.IntegerField(blank=True, null=True)),
('listing_count', models.IntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='price_trends', to='fonrey_complex.complex')),
],
options={
'db_table': 'complex_price_trends',
'ordering': ['complex_id', '-record_month'],
},
),
migrations.CreateModel(
name='ComplexPhoto',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('category', models.CharField(choices=[('complex', '楼盘图'), ('layout', '户型图'), ('vr', 'VR图'), ('other', '其他')], 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, help_text='bytes', 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)),
('created_at', models.DateTimeField(auto_now_add=True)),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fonrey_complex.complex')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_photos', to='org.staff')),
],
options={
'db_table': 'complex_photos',
'ordering': ['complex_id', 'sort_order'],
},
),
migrations.CreateModel(
name='ComplexMetroStation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('distance_meters', models.IntegerField(blank=True, null=True)),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_metro_stations', to='fonrey_complex.complex')),
('station', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.metrostation')),
],
options={
'db_table': 'complex_metro_stations',
},
),
migrations.CreateModel(
name='ComplexBusinessArea',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_primary', models.BooleanField(default=False)),
('business_area', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.businessarea')),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_business_areas', to='fonrey_complex.complex')),
],
options={
'db_table': 'complex_business_areas',
},
),
migrations.CreateModel(
name='ComplexAttachment',
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)),
('file_size', models.IntegerField(blank=True, null=True)),
('file_type', models.CharField(blank=True, default='', help_text='MIME type', max_length=50)),
('sort_order', models.SmallIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fonrey_complex.complex')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_attachments', to='org.staff')),
],
options={
'db_table': 'complex_attachments',
'ordering': ['complex_id', 'sort_order'],
},
),
migrations.CreateModel(
name='ComplexAlias',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('alias', models.CharField(max_length=200)),
('is_system', models.BooleanField(default=False, help_text='TRUE=系统/标准别名(只读)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='fonrey_complex.complex')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_aliases', to='org.staff')),
],
options={
'db_table': 'complex_aliases',
'ordering': ['complex_id', 'alias'],
},
),
migrations.AddField(
model_name='complex',
name='business_areas',
field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexBusinessArea', to='region.businessarea'),
),
migrations.AddField(
model_name='complex',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complexes', to='org.staff'),
),
migrations.AddField(
model_name='complex',
name='district',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complexes', to='region.district'),
),
migrations.AddField(
model_name='complex',
name='metro_stations',
field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexMetroStation', to='region.metrostation'),
),
migrations.AddField(
model_name='complex',
name='schools',
field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexSchool', to='region.school'),
),
migrations.AddField(
model_name='complex',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_complexes', to='org.staff'),
),
migrations.CreateModel(
name='Building',
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)),
('name', models.CharField(help_text='楼栋名如「1号楼」「A栋2单元」', max_length=50)),
('is_standard', models.BooleanField(default=False, help_text='TRUE=标准结构(经运营核准)')),
('property_usage_type', models.CharField(blank=True, choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('commercial', '商业'), ('office', '写字楼'), ('other', '其他')], default='', max_length=30)),
('built_year', models.SmallIntegerField(blank=True, null=True)),
('total_floors', models.SmallIntegerField(blank=True, null=True)),
('land_use_years', models.CharField(blank=True, default='', max_length=30)),
('has_elevator', models.BooleanField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('complex', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='buildings', to='fonrey_complex.complex')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_buildings', to='org.staff')),
('school', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='buildings', to='region.school')),
],
options={
'db_table': 'buildings',
'ordering': ['complex_id', 'name'],
},
),
migrations.CreateModel(
name='RoomUnit',
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)),
('floor', models.SmallIntegerField(help_text='楼层(实际层数,地下为负数)')),
('floor_name', models.CharField(blank=True, default='', max_length=20)),
('room_no', models.CharField(max_length=30)),
('display_no', models.CharField(blank=True, default='', max_length=50)),
('is_standard', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='room_units', to='fonrey_complex.building')),
],
options={
'db_table': 'room_units',
'ordering': ['building_id', '-floor', 'room_no'],
'indexes': [models.Index(condition=models.Q(('is_active', True)), fields=['building'], name='idx_room_units_building')],
},
),
migrations.AddConstraint(
model_name='roomunit',
constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('building', 'floor', 'room_no'), name='uq_room_units_unique'),
),
migrations.AddIndex(
model_name='complexschool',
index=models.Index(fields=['school'], name='idx_complex_schools_school'),
),
migrations.AddConstraint(
model_name='complexschool',
constraint=models.UniqueConstraint(fields=('complex', 'school'), name='pk_complex_schools'),
),
migrations.AddIndex(
model_name='complexpricetrend',
index=models.Index(fields=['complex', '-record_month'], name='idx_cpx_price_trend_complex'),
),
migrations.AddConstraint(
model_name='complexpricetrend',
constraint=models.UniqueConstraint(fields=('complex', 'record_month'), name='uq_complex_price_trend_month'),
),
migrations.AddIndex(
model_name='complexphoto',
index=models.Index(fields=['complex'], name='idx_complex_photos_complex'),
),
migrations.AddIndex(
model_name='complexphoto',
index=models.Index(fields=['complex', 'category'], name='idx_complex_photos_category'),
),
migrations.AddConstraint(
model_name='complexphoto',
constraint=models.UniqueConstraint(condition=models.Q(('is_cover', True)), fields=('complex',), name='uq_complex_photos_cover'),
),
migrations.AddIndex(
model_name='complexmetrostation',
index=models.Index(fields=['complex'], name='idx_complex_metro_complex'),
),
migrations.AddIndex(
model_name='complexmetrostation',
index=models.Index(fields=['station'], name='idx_complex_metro_station'),
),
migrations.AddConstraint(
model_name='complexmetrostation',
constraint=models.UniqueConstraint(fields=('complex', 'station'), name='pk_complex_metro_stations'),
),
migrations.AddConstraint(
model_name='complexbusinessarea',
constraint=models.UniqueConstraint(fields=('complex', 'business_area'), name='pk_complex_business_areas'),
),
migrations.AddConstraint(
model_name='complexbusinessarea',
constraint=models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('complex',), name='uq_complex_biz_area_primary'),
),
migrations.AddIndex(
model_name='complexalias',
index=models.Index(fields=['complex'], name='idx_complex_aliases_complex'),
),
migrations.AddIndex(
model_name='complex',
index=models.Index(condition=models.Q(('deleted_at__isnull', True)), fields=['district'], name='idx_complexes_district'),
),
migrations.AddIndex(
model_name='complex',
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='idx_complexes_search'),
),
migrations.AddIndex(
model_name='complex',
index=models.Index(condition=models.Q(('deleted_at__isnull', True), ('latitude__isnull', False)), fields=['latitude', 'longitude'], name='idx_complexes_geo'),
),
migrations.AddIndex(
model_name='complex',
index=models.Index(condition=models.Q(('deleted_at__isnull', True)), fields=['is_active'], name='idx_complexes_active'),
),
migrations.AddIndex(
model_name='building',
index=models.Index(condition=models.Q(('is_active', True)), fields=['complex'], name='idx_buildings_complex'),
),
migrations.AddConstraint(
model_name='building',
constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('complex', 'name'), name='uq_buildings_complex_name'),
),
]

View File

@@ -0,0 +1,76 @@
from django.db import migrations
SQL_FORWARDS = r"""
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_complexes_name_trgm
ON complexes USING gin (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_complex_aliases_alias_trgm
ON complex_aliases USING gin (alias gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_schools_name_trgm
ON schools USING gin (name gin_trgm_ops);
CREATE OR REPLACE FUNCTION update_complex_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', COALESCE(NEW.name, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW.address_summary, '')), 'B') ||
setweight(to_tsvector('simple', COALESCE(NEW.address, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_complex_search_vector ON complexes;
CREATE TRIGGER trg_complex_search_vector
BEFORE INSERT OR UPDATE OF name, address_summary, address
ON complexes
FOR EACH ROW EXECUTE FUNCTION update_complex_search_vector();
CREATE OR REPLACE FUNCTION update_complex_search_on_alias()
RETURNS TRIGGER AS $$
BEGIN
UPDATE complexes
SET search_vector = (
setweight(to_tsvector('simple', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('simple',
COALESCE((SELECT string_agg(alias, ' ') FROM complex_aliases WHERE complex_id = complexes.id), '')), 'B') ||
setweight(to_tsvector('simple', COALESCE(address_summary, '')), 'C') ||
setweight(to_tsvector('simple', COALESCE(address, '')), 'D')
),
updated_at = NOW()
WHERE id = COALESCE(NEW.complex_id, OLD.complex_id);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_complex_alias_search ON complex_aliases;
CREATE TRIGGER trg_complex_alias_search
AFTER INSERT OR UPDATE OR DELETE ON complex_aliases
FOR EACH ROW EXECUTE FUNCTION update_complex_search_on_alias();
"""
SQL_REVERSE = r"""
DROP TRIGGER IF EXISTS trg_complex_alias_search ON complex_aliases;
DROP TRIGGER IF EXISTS trg_complex_search_vector ON complexes;
DROP FUNCTION IF EXISTS update_complex_search_on_alias();
DROP FUNCTION IF EXISTS update_complex_search_vector();
DROP INDEX IF EXISTS idx_schools_name_trgm;
DROP INDEX IF EXISTS idx_complex_aliases_alias_trgm;
DROP INDEX IF EXISTS idx_complexes_name_trgm;
"""
class Migration(migrations.Migration):
dependencies = [
("fonrey_complex", "0001_initial"),
("region", "0001_initial"),
]
operations = [
migrations.RunSQL(sql=SQL_FORWARDS, reverse_sql=SQL_REVERSE),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fonrey_complex', '0002_pg_trgm_and_search_vector'),
]
operations = [
migrations.AlterModelOptions(
name='building',
options={'ordering': ['complex_id', 'name'], 'verbose_name': '楼栋', 'verbose_name_plural': '楼栋'},
),
migrations.AlterModelOptions(
name='complex',
options={'ordering': ['name'], 'verbose_name': '楼盘', 'verbose_name_plural': '楼盘'},
),
migrations.AlterModelOptions(
name='complexalias',
options={'ordering': ['complex_id', 'alias'], 'verbose_name': '楼盘别名', 'verbose_name_plural': '楼盘别名'},
),
migrations.AlterModelOptions(
name='complexattachment',
options={'ordering': ['complex_id', 'sort_order'], 'verbose_name': '楼盘附件', 'verbose_name_plural': '楼盘附件'},
),
migrations.AlterModelOptions(
name='complexbusinessarea',
options={'verbose_name': '楼盘商圈关联', 'verbose_name_plural': '楼盘商圈关联'},
),
migrations.AlterModelOptions(
name='complexmetrostation',
options={'verbose_name': '楼盘地铁站关联', 'verbose_name_plural': '楼盘地铁站关联'},
),
migrations.AlterModelOptions(
name='complexphoto',
options={'ordering': ['complex_id', 'sort_order'], 'verbose_name': '楼盘照片', 'verbose_name_plural': '楼盘照片'},
),
migrations.AlterModelOptions(
name='complexpricetrend',
options={'ordering': ['complex_id', '-record_month'], 'verbose_name': '楼盘价格走势', 'verbose_name_plural': '楼盘价格走势'},
),
migrations.AlterModelOptions(
name='complexschool',
options={'verbose_name': '楼盘学校关联', 'verbose_name_plural': '楼盘学校关联'},
),
migrations.AlterModelOptions(
name='roomunit',
options={'ordering': ['building_id', '-floor', 'room_no'], 'verbose_name': '房号单元', 'verbose_name_plural': '房号单元'},
),
]

View File

@@ -0,0 +1,528 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
import django.contrib.postgres.fields
import django.contrib.postgres.search
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('org', '0003_alter_orgunit_address_city_and_more'),
('region', '0003_alter_businessarea_district_and_more'),
('fonrey_complex', '0003_alter_building_options_alter_complex_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='building',
name='built_year',
field=models.SmallIntegerField(blank=True, null=True, verbose_name='竣工年份'),
),
migrations.AlterField(
model_name='building',
name='complex',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='buildings', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='building',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_buildings', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='building',
name='has_elevator',
field=models.BooleanField(blank=True, null=True, verbose_name='是否有电梯'),
),
migrations.AlterField(
model_name='building',
name='is_active',
field=models.BooleanField(default=True, help_text='FALSE=已停用(楼栋被删除或合并)', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='building',
name='is_standard',
field=models.BooleanField(default=False, help_text='TRUE=已经运营核准', verbose_name='是否标准结构'),
),
migrations.AlterField(
model_name='building',
name='land_use_years',
field=models.CharField(blank=True, default='', max_length=30, verbose_name='土地使用年限'),
),
migrations.AlterField(
model_name='building',
name='name',
field=models.CharField(help_text='如「1号楼」「A栋2单元」', max_length=50, verbose_name='楼栋名称'),
),
migrations.AlterField(
model_name='building',
name='property_usage_type',
field=models.CharField(blank=True, choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('commercial', '商业'), ('office', '写字楼'), ('other', '其他')], default='', help_text='可与楼盘不同,如商住楼盘内有纯商铺楼栋', max_length=30, verbose_name='物业类型'),
),
migrations.AlterField(
model_name='building',
name='school',
field=models.ForeignKey(blank=True, help_text='楼栋级别的学区差异', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='buildings', to='region.school', verbose_name='对口学校'),
),
migrations.AlterField(
model_name='building',
name='total_floors',
field=models.SmallIntegerField(blank=True, null=True, verbose_name='总层数'),
),
migrations.AlterField(
model_name='complex',
name='address',
field=models.CharField(blank=True, default='', help_text='不可在编辑页修改,需走纠错流程', max_length=500, verbose_name='详细地址'),
),
migrations.AlterField(
model_name='complex',
name='address_summary',
field=models.CharField(blank=True, default='', help_text='如「海波路1000弄」可编辑', max_length=100, verbose_name='概要地址'),
),
migrations.AlterField(
model_name='complex',
name='building_structure',
field=models.CharField(blank=True, choices=[('unit_room', '单元-房号'), ('other', '其他')], default='', help_text='unit_room=单元-房号 / other=其他', max_length=30, verbose_name='楼栋结构'),
),
migrations.AlterField(
model_name='complex',
name='building_type',
field=models.CharField(blank=True, choices=[('slab', '板楼'), ('tower', '塔楼'), ('slab_tower', '板塔结合')], default='', help_text='slab=板楼 / tower=塔楼 / slab_tower=板塔结合', max_length=20, verbose_name='建筑类型'),
),
migrations.AlterField(
model_name='complex',
name='built_year',
field=models.SmallIntegerField(blank=True, help_text='可多选时存最早竣工年', null=True, verbose_name='竣工年份'),
),
migrations.AlterField(
model_name='complex',
name='built_years',
field=django.contrib.postgres.fields.ArrayField(base_field=models.SmallIntegerField(), blank=True, default=list, help_text='楼盘分期竣工', size=None, verbose_name='竣工年份多值'),
),
migrations.AlterField(
model_name='complex',
name='business_areas',
field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexBusinessArea', to='region.businessarea', verbose_name='关联商圈'),
),
migrations.AlterField(
model_name='complex',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complexes', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='complex',
name='developer',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='开发商'),
),
migrations.AlterField(
model_name='complex',
name='district',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complexes', to='region.district', verbose_name='所属城区'),
),
migrations.AlterField(
model_name='complex',
name='electricity_type',
field=models.CharField(blank=True, choices=[('civil', '民电'), ('commercial', '商电')], default='', help_text='civil=民电 / commercial=商电', max_length=10, verbose_name='电费类型'),
),
migrations.AlterField(
model_name='complex',
name='green_rate',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:%', max_digits=5, null=True, verbose_name='绿化率'),
),
migrations.AlterField(
model_name='complex',
name='has_central_heating',
field=models.BooleanField(blank=True, null=True, verbose_name='是否统一供暖'),
),
migrations.AlterField(
model_name='complex',
name='has_gas',
field=models.BooleanField(blank=True, null=True, verbose_name='是否有燃气'),
),
migrations.AlterField(
model_name='complex',
name='is_active',
field=models.BooleanField(default=True, help_text='FALSE=已停用楼盘', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='complex',
name='land_use_years',
field=models.CharField(blank=True, default='', help_text='如「70年」', max_length=30, verbose_name='土地使用年限'),
),
migrations.AlterField(
model_name='complex',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=7, help_text='WGS84完整度目标 ≥ 90%', max_digits=10, null=True, verbose_name='纬度'),
),
migrations.AlterField(
model_name='complex',
name='lock_building',
field=models.BooleanField(default=False, help_text='锁定后不可增删楼栋', verbose_name='楼栋锁'),
),
migrations.AlterField(
model_name='complex',
name='lock_info',
field=models.BooleanField(default=False, help_text='锁定后基本信息只读', verbose_name='信息锁'),
),
migrations.AlterField(
model_name='complex',
name='lock_room',
field=models.BooleanField(default=False, verbose_name='房号锁'),
),
migrations.AlterField(
model_name='complex',
name='lock_standard_room',
field=models.BooleanField(default=False, verbose_name='标准房号锁'),
),
migrations.AlterField(
model_name='complex',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=7, help_text='WGS84', max_digits=10, null=True, verbose_name='经度'),
),
migrations.AlterField(
model_name='complex',
name='metro_stations',
field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexMetroStation', to='region.metrostation', verbose_name='周边地铁站'),
),
migrations.AlterField(
model_name='complex',
name='name',
field=models.CharField(help_text='标准楼盘名称,不可在编辑页直接修改(需走合并/申请流程)', max_length=200, verbose_name='楼盘名称'),
),
migrations.AlterField(
model_name='complex',
name='ownership_category',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, default=list, help_text='多选(运营维护枚举)', size=None, verbose_name='权属类别'),
),
migrations.AlterField(
model_name='complex',
name='parking_ratio',
field=models.CharField(blank=True, default='', help_text='如「100:63」', max_length=20, verbose_name='车位配比'),
),
migrations.AlterField(
model_name='complex',
name='parking_total',
field=models.IntegerField(blank=True, null=True, verbose_name='车位总数'),
),
migrations.AlterField(
model_name='complex',
name='parking_underground',
field=models.IntegerField(blank=True, null=True, verbose_name='地下车位数'),
),
migrations.AlterField(
model_name='complex',
name='plot_area',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位', max_digits=12, null=True, verbose_name='小区占地面积'),
),
migrations.AlterField(
model_name='complex',
name='plot_ratio',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='容积率'),
),
migrations.AlterField(
model_name='complex',
name='property_company',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='物业公司'),
),
migrations.AlterField(
model_name='complex',
name='property_fee',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:元/m²/月', max_digits=8, null=True, verbose_name='物业费'),
),
migrations.AlterField(
model_name='complex',
name='property_phone',
field=models.CharField(blank=True, default='', max_length=30, verbose_name='物业电话'),
),
migrations.AlterField(
model_name='complex',
name='property_usage_types',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('commercial', '商业'), ('office', '写字楼'), ('other', '其他')], max_length=30), blank=True, default=list, help_text='多选residential / villa / commercial_residential / commercial / office / other', size=None, verbose_name='物业类型'),
),
migrations.AlterField(
model_name='complex',
name='remarks',
field=models.TextField(blank=True, default='', verbose_name='备注'),
),
migrations.AlterField(
model_name='complex',
name='schools',
field=models.ManyToManyField(related_name='complexes', through='fonrey_complex.ComplexSchool', to='region.school', verbose_name='对口学校'),
),
migrations.AlterField(
model_name='complex',
name='search_vector',
field=django.contrib.postgres.search.SearchVectorField(blank=True, help_text='由触发器自动维护name + alias + address', null=True, verbose_name='全文检索向量'),
),
migrations.AlterField(
model_name='complex',
name='total_floor_area',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位', max_digits=12, null=True, verbose_name='小区总建筑面积'),
),
migrations.AlterField(
model_name='complex',
name='total_households',
field=models.IntegerField(blank=True, null=True, verbose_name='总户数'),
),
migrations.AlterField(
model_name='complex',
name='total_units',
field=models.IntegerField(blank=True, null=True, verbose_name='单元总数'),
),
migrations.AlterField(
model_name='complex',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_complexes', to='org.staff', verbose_name='最后更新人'),
),
migrations.AlterField(
model_name='complex',
name='version',
field=models.IntegerField(default=1, help_text='乐观锁UPDATE 时 +1应用层检测 0 行受影响时抛 ConflictError', verbose_name='版本号'),
),
migrations.AlterField(
model_name='complex',
name='water_type',
field=models.CharField(blank=True, choices=[('civil', '民水'), ('commercial', '商水')], default='', help_text='civil=民水 / commercial=商水', max_length=10, verbose_name='水费类型'),
),
migrations.AlterField(
model_name='complexalias',
name='alias',
field=models.CharField(help_text='最多20字/条,多别名多行存储', max_length=200, verbose_name='别名'),
),
migrations.AlterField(
model_name='complexalias',
name='complex',
field=models.ForeignKey(help_text='别名随楼盘级联删除', on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='complexalias',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='complexalias',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_aliases', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='complexalias',
name='is_system',
field=models.BooleanField(default=False, help_text='TRUE=系统/标准别名只读FALSE=用户自定义', verbose_name='是否系统别名'),
),
migrations.AlterField(
model_name='complexattachment',
name='complex',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='complexattachment',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='complexattachment',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_attachments', to='org.staff', verbose_name='上传人'),
),
migrations.AlterField(
model_name='complexattachment',
name='file_key',
field=models.TextField(help_text='R2/S3 存储路径', verbose_name='文件存储路径'),
),
migrations.AlterField(
model_name='complexattachment',
name='file_name',
field=models.CharField(max_length=255, verbose_name='原始文件名'),
),
migrations.AlterField(
model_name='complexattachment',
name='file_size',
field=models.IntegerField(blank=True, help_text='单位bytes', null=True, verbose_name='文件大小'),
),
migrations.AlterField(
model_name='complexattachment',
name='file_type',
field=models.CharField(blank=True, default='', help_text='MIME type', max_length=50, verbose_name='文件类型'),
),
migrations.AlterField(
model_name='complexattachment',
name='sort_order',
field=models.SmallIntegerField(default=0, verbose_name='排序顺序'),
),
migrations.AlterField(
model_name='complexbusinessarea',
name='business_area',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.businessarea', verbose_name='关联商圈'),
),
migrations.AlterField(
model_name='complexbusinessarea',
name='complex',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_business_areas', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='complexbusinessarea',
name='is_primary',
field=models.BooleanField(default=False, help_text='主商圈唯一,用于列表显示', verbose_name='是否主商圈'),
),
migrations.AlterField(
model_name='complexmetrostation',
name='complex',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_metro_stations', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='complexmetrostation',
name='distance_meters',
field=models.IntegerField(blank=True, help_text='单位:米', null=True, verbose_name='步行距离'),
),
migrations.AlterField(
model_name='complexmetrostation',
name='station',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.metrostation', verbose_name='关联地铁站'),
),
migrations.AlterField(
model_name='complexphoto',
name='category',
field=models.CharField(choices=[('complex', '楼盘图'), ('layout', '户型图'), ('vr', 'VR图'), ('other', '其他')], help_text='complex=楼盘图 / layout=户型图 / vr=VR全景 / other=其他', max_length=20, verbose_name='照片类别'),
),
migrations.AlterField(
model_name='complexphoto',
name='complex',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='complexphoto',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='complexphoto',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_complex_photos', to='org.staff', verbose_name='上传人'),
),
migrations.AlterField(
model_name='complexphoto',
name='file_key',
field=models.TextField(help_text='R2/S3 路径', verbose_name='文件存储路径'),
),
migrations.AlterField(
model_name='complexphoto',
name='file_name',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='原始文件名'),
),
migrations.AlterField(
model_name='complexphoto',
name='file_size',
field=models.IntegerField(blank=True, help_text='单位bytes', null=True, verbose_name='文件大小'),
),
migrations.AlterField(
model_name='complexphoto',
name='height',
field=models.IntegerField(blank=True, help_text='单位px', null=True, verbose_name='图片高度'),
),
migrations.AlterField(
model_name='complexphoto',
name='is_cover',
field=models.BooleanField(default=False, help_text='楼盘封面图(每楼盘唯一)', verbose_name='是否封面图'),
),
migrations.AlterField(
model_name='complexphoto',
name='sort_order',
field=models.SmallIntegerField(default=0, help_text='同类别内的排序顺序', verbose_name='排序顺序'),
),
migrations.AlterField(
model_name='complexphoto',
name='thumbnail_key',
field=models.TextField(blank=True, default='', verbose_name='缩略图路径'),
),
migrations.AlterField(
model_name='complexphoto',
name='width',
field=models.IntegerField(blank=True, help_text='单位px', null=True, verbose_name='图片宽度'),
),
migrations.AlterField(
model_name='complexpricetrend',
name='avg_sale_price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:万元/套', max_digits=12, null=True, verbose_name='月均售价'),
),
migrations.AlterField(
model_name='complexpricetrend',
name='avg_unit_price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='单位:元/m²', max_digits=10, null=True, verbose_name='月均单价'),
),
migrations.AlterField(
model_name='complexpricetrend',
name='complex',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='price_trends', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='complexpricetrend',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='complexpricetrend',
name='listing_count',
field=models.IntegerField(blank=True, null=True, verbose_name='当月挂牌套数'),
),
migrations.AlterField(
model_name='complexpricetrend',
name='record_month',
field=models.DateField(help_text='统一存为该月1日如 2026-04-01', verbose_name='月份'),
),
migrations.AlterField(
model_name='complexpricetrend',
name='transaction_count',
field=models.IntegerField(blank=True, null=True, verbose_name='成交套数'),
),
migrations.AlterField(
model_name='complexschool',
name='complex',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_schools', to='fonrey_complex.complex', verbose_name='所属楼盘'),
),
migrations.AlterField(
model_name='complexschool',
name='school',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complex_links', to='region.school', verbose_name='对口学校'),
),
migrations.AlterField(
model_name='complexschool',
name='zone_type',
field=models.CharField(blank=True, choices=[('guaranteed', '对口'), ('reference', '参考'), ('lottery', '摇号')], default='', help_text='guaranteed=对口(直升) / reference=参考(可能入读) / lottery=摇号', max_length=30, verbose_name='学区类型'),
),
migrations.AlterField(
model_name='roomunit',
name='building',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='room_units', to='fonrey_complex.building', verbose_name='所属楼栋'),
),
migrations.AlterField(
model_name='roomunit',
name='display_no',
field=models.CharField(blank=True, default='', help_text='展示用完整房号如「3-1-101」', max_length=50, verbose_name='展示房号'),
),
migrations.AlterField(
model_name='roomunit',
name='floor',
field=models.SmallIntegerField(help_text='实际层数,地下为负数', verbose_name='楼层'),
),
migrations.AlterField(
model_name='roomunit',
name='floor_name',
field=models.CharField(blank=True, default='', help_text='如「1层」「B1层」', max_length=20, verbose_name='楼层名称'),
),
migrations.AlterField(
model_name='roomunit',
name='is_active',
field=models.BooleanField(default=True, help_text='FALSE=已拆除/不存在', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='roomunit',
name='is_standard',
field=models.BooleanField(default=False, help_text='TRUE=已归一化为标准结构', verbose_name='是否标准化'),
),
migrations.AlterField(
model_name='roomunit',
name='room_no',
field=models.CharField(help_text='如「01」「101」', max_length=30, verbose_name='房号'),
),
]

View File

@@ -0,0 +1,25 @@
from apps.complex.models.complex import (
Building,
Complex,
ComplexAlias,
ComplexAttachment,
ComplexBusinessArea,
ComplexMetroStation,
ComplexPhoto,
ComplexPriceTrend,
ComplexSchool,
RoomUnit,
)
__all__ = [
"Building",
"Complex",
"ComplexAlias",
"ComplexAttachment",
"ComplexBusinessArea",
"ComplexMetroStation",
"ComplexPhoto",
"ComplexPriceTrend",
"ComplexSchool",
"RoomUnit",
]

View File

@@ -0,0 +1,841 @@
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.db import models
from core.enums import (
ComplexBuildingStructure,
ComplexBuildingType,
ComplexElectricityType,
ComplexPhotoCategory,
ComplexPropertyUsageType,
ComplexWaterType,
SchoolZoneType,
)
from core.models.base import SoftDeleteModel, TimeStampedModel, UUIDPrimaryKeyModel
class Complex(SoftDeleteModel):
name = models.CharField(
max_length=200,
verbose_name="楼盘名称",
help_text="标准楼盘名称,不可在编辑页直接修改(需走合并/申请流程)",
)
district = models.ForeignKey(
"region.District",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="complexes",
verbose_name="所属城区",
)
address = models.CharField(
max_length=500,
blank=True,
default="",
verbose_name="详细地址",
help_text="不可在编辑页修改,需走纠错流程",
)
address_summary = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="概要地址",
help_text='如「海波路1000弄」可编辑',
)
latitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="纬度",
help_text="WGS84完整度目标 ≥ 90%",
)
longitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="经度",
help_text="WGS84",
)
property_usage_types = ArrayField(
models.CharField(max_length=30, choices=ComplexPropertyUsageType.choices),
default=list,
blank=True,
verbose_name="物业类型",
help_text="多选residential / villa / commercial_residential / commercial / office / other",
)
building_structure = models.CharField(
max_length=30,
blank=True,
default="",
choices=ComplexBuildingStructure.choices,
verbose_name="楼栋结构",
help_text="unit_room=单元-房号 / other=其他",
)
building_type = models.CharField(
max_length=20,
blank=True,
default="",
choices=ComplexBuildingType.choices,
verbose_name="建筑类型",
help_text="slab=板楼 / tower=塔楼 / slab_tower=板塔结合",
)
land_use_years = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="土地使用年限",
help_text='如「70年」',
)
built_year = models.SmallIntegerField(
null=True,
blank=True,
verbose_name="竣工年份",
help_text="可多选时存最早竣工年",
)
built_years = ArrayField(
models.SmallIntegerField(),
default=list,
blank=True,
verbose_name="竣工年份多值",
help_text="楼盘分期竣工",
)
ownership_category = ArrayField(
models.CharField(max_length=30),
default=list,
blank=True,
verbose_name="权属类别",
help_text="多选(运营维护枚举)",
)
total_units = models.IntegerField(
null=True,
blank=True,
verbose_name="单元总数",
)
total_households = models.IntegerField(
null=True,
blank=True,
verbose_name="总户数",
)
total_floor_area = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="小区总建筑面积",
help_text="单位",
)
plot_area = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="小区占地面积",
help_text="单位",
)
plot_ratio = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
verbose_name="容积率",
)
green_rate = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
verbose_name="绿化率",
help_text="单位:%",
)
developer = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="开发商",
)
property_company = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="物业公司",
)
property_fee = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
verbose_name="物业费",
help_text="单位:元/m²/月",
)
property_phone = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="物业电话",
)
parking_total = models.IntegerField(
null=True,
blank=True,
verbose_name="车位总数",
)
parking_underground = models.IntegerField(
null=True,
blank=True,
verbose_name="地下车位数",
)
parking_ratio = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="车位配比",
help_text='如「100:63」',
)
water_type = models.CharField(
max_length=10,
blank=True,
default="",
choices=ComplexWaterType.choices,
verbose_name="水费类型",
help_text="civil=民水 / commercial=商水",
)
electricity_type = models.CharField(
max_length=10,
blank=True,
default="",
choices=ComplexElectricityType.choices,
verbose_name="电费类型",
help_text="civil=民电 / commercial=商电",
)
has_central_heating = models.BooleanField(
null=True,
blank=True,
verbose_name="是否统一供暖",
)
has_gas = models.BooleanField(
null=True,
blank=True,
verbose_name="是否有燃气",
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="备注",
)
lock_building = models.BooleanField(
default=False,
verbose_name="楼栋锁",
help_text="锁定后不可增删楼栋",
)
lock_room = models.BooleanField(
default=False,
verbose_name="房号锁",
)
lock_info = models.BooleanField(
default=False,
verbose_name="信息锁",
help_text="锁定后基本信息只读",
)
lock_standard_room = models.BooleanField(
default=False,
verbose_name="标准房号锁",
)
search_vector = SearchVectorField(
null=True,
blank=True,
verbose_name="全文检索向量",
help_text="由触发器自动维护name + alias + address",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="FALSE=已停用楼盘",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complexes",
verbose_name="创建人",
)
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_complexes",
verbose_name="最后更新人",
)
version = models.IntegerField(
default=1,
verbose_name="版本号",
help_text="乐观锁UPDATE 时 +1应用层检测 0 行受影响时抛 ConflictError",
)
business_areas = models.ManyToManyField(
"region.BusinessArea",
through="fonrey_complex.ComplexBusinessArea",
related_name="complexes",
verbose_name="关联商圈",
)
schools = models.ManyToManyField(
"region.School",
through="fonrey_complex.ComplexSchool",
related_name="complexes",
verbose_name="对口学校",
)
metro_stations = models.ManyToManyField(
"region.MetroStation",
through="fonrey_complex.ComplexMetroStation",
related_name="complexes",
verbose_name="周边地铁站",
)
class Meta:
db_table = "complexes"
verbose_name = "楼盘"
verbose_name_plural = "楼盘"
indexes = [
models.Index(
fields=["district"],
name="idx_complexes_district",
condition=models.Q(deleted_at__isnull=True),
),
GinIndex(fields=["search_vector"], name="idx_complexes_search"),
models.Index(
fields=["latitude", "longitude"],
name="idx_complexes_geo",
condition=models.Q(deleted_at__isnull=True, latitude__isnull=False),
),
models.Index(
fields=["is_active"],
name="idx_complexes_active",
condition=models.Q(deleted_at__isnull=True),
),
]
ordering = ["name"]
def __str__(self) -> str:
return self.name
class ComplexAlias(UUIDPrimaryKeyModel):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="aliases",
verbose_name="所属楼盘",
help_text="别名随楼盘级联删除",
)
alias = models.CharField(
max_length=200,
verbose_name="别名",
help_text="最多20字/条,多别名多行存储",
)
is_system = models.BooleanField(
default=False,
verbose_name="是否系统别名",
help_text="TRUE=系统/标准别名只读FALSE=用户自定义",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complex_aliases",
verbose_name="创建人",
)
class Meta:
db_table = "complex_aliases"
verbose_name = "楼盘别名"
verbose_name_plural = "楼盘别名"
indexes = [
models.Index(fields=["complex"], name="idx_complex_aliases_complex"),
]
ordering = ["complex_id", "alias"]
def __str__(self) -> str:
return self.alias
class ComplexBusinessArea(models.Model):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="complex_business_areas",
verbose_name="所属楼盘",
)
business_area = models.ForeignKey(
"region.BusinessArea",
on_delete=models.CASCADE,
related_name="complex_links",
verbose_name="关联商圈",
)
is_primary = models.BooleanField(
default=False,
verbose_name="是否主商圈",
help_text="主商圈唯一,用于列表显示",
)
class Meta:
db_table = "complex_business_areas"
verbose_name = "楼盘商圈关联"
verbose_name_plural = "楼盘商圈关联"
constraints = [
models.UniqueConstraint(
fields=["complex", "business_area"],
name="pk_complex_business_areas",
),
models.UniqueConstraint(
fields=["complex"],
condition=models.Q(is_primary=True),
name="uq_complex_biz_area_primary",
),
]
class ComplexSchool(models.Model):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="complex_schools",
verbose_name="所属楼盘",
)
school = models.ForeignKey(
"region.School",
on_delete=models.CASCADE,
related_name="complex_links",
verbose_name="对口学校",
)
zone_type = models.CharField(
max_length=30,
blank=True,
default="",
choices=SchoolZoneType.choices,
verbose_name="学区类型",
help_text="guaranteed=对口(直升) / reference=参考(可能入读) / lottery=摇号",
)
class Meta:
db_table = "complex_schools"
verbose_name = "楼盘学校关联"
verbose_name_plural = "楼盘学校关联"
constraints = [
models.UniqueConstraint(
fields=["complex", "school"],
name="pk_complex_schools",
),
]
indexes = [
models.Index(fields=["school"], name="idx_complex_schools_school"),
]
class ComplexMetroStation(models.Model):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="complex_metro_stations",
verbose_name="所属楼盘",
)
station = models.ForeignKey(
"region.MetroStation",
on_delete=models.CASCADE,
related_name="complex_links",
verbose_name="关联地铁站",
)
distance_meters = models.IntegerField(
null=True,
blank=True,
verbose_name="步行距离",
help_text="单位:米",
)
class Meta:
db_table = "complex_metro_stations"
verbose_name = "楼盘地铁站关联"
verbose_name_plural = "楼盘地铁站关联"
constraints = [
models.UniqueConstraint(
fields=["complex", "station"],
name="pk_complex_metro_stations",
),
]
indexes = [
models.Index(fields=["complex"], name="idx_complex_metro_complex"),
models.Index(fields=["station"], name="idx_complex_metro_station"),
]
class Building(TimeStampedModel):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="buildings",
verbose_name="所属楼盘",
)
name = models.CharField(
max_length=50,
verbose_name="楼栋名称",
help_text='如「1号楼」「A栋2单元」',
)
is_standard = models.BooleanField(
default=False,
verbose_name="是否标准结构",
help_text="TRUE=已经运营核准",
)
property_usage_type = models.CharField(
max_length=30,
blank=True,
default="",
choices=ComplexPropertyUsageType.choices,
verbose_name="物业类型",
help_text="可与楼盘不同,如商住楼盘内有纯商铺楼栋",
)
built_year = models.SmallIntegerField(
null=True,
blank=True,
verbose_name="竣工年份",
)
total_floors = models.SmallIntegerField(
null=True,
blank=True,
verbose_name="总层数",
)
land_use_years = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="土地使用年限",
)
has_elevator = models.BooleanField(
null=True,
blank=True,
verbose_name="是否有电梯",
)
school = models.ForeignKey(
"region.School",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="buildings",
verbose_name="对口学校",
help_text="楼栋级别的学区差异",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="FALSE=已停用(楼栋被删除或合并)",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_buildings",
verbose_name="创建人",
)
class Meta:
db_table = "buildings"
verbose_name = "楼栋"
verbose_name_plural = "楼栋"
indexes = [
models.Index(
fields=["complex"],
name="idx_buildings_complex",
condition=models.Q(is_active=True),
),
]
constraints = [
models.UniqueConstraint(
fields=["complex", "name"],
condition=models.Q(is_active=True),
name="uq_buildings_complex_name",
),
]
ordering = ["complex_id", "name"]
def __str__(self) -> str:
return self.name
class RoomUnit(TimeStampedModel):
building = models.ForeignKey(
"fonrey_complex.Building",
on_delete=models.CASCADE,
related_name="room_units",
verbose_name="所属楼栋",
)
floor = models.SmallIntegerField(
verbose_name="楼层",
help_text="实际层数,地下为负数",
)
floor_name = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="楼层名称",
help_text='如「1层」「B1层」',
)
room_no = models.CharField(
max_length=30,
verbose_name="房号",
help_text='如「01」「101」',
)
display_no = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="展示房号",
help_text='展示用完整房号如「3-1-101」',
)
is_standard = models.BooleanField(
default=False,
verbose_name="是否标准化",
help_text="TRUE=已归一化为标准结构",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="FALSE=已拆除/不存在",
)
class Meta:
db_table = "room_units"
verbose_name = "房号单元"
verbose_name_plural = "房号单元"
indexes = [
models.Index(
fields=["building"],
name="idx_room_units_building",
condition=models.Q(is_active=True),
),
]
constraints = [
models.UniqueConstraint(
fields=["building", "floor", "room_no"],
condition=models.Q(is_active=True),
name="uq_room_units_unique",
),
]
ordering = ["building_id", "-floor", "room_no"]
def __str__(self) -> str:
return self.display_no or f"{self.floor}/{self.room_no}"
class ComplexPhoto(UUIDPrimaryKeyModel):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="photos",
verbose_name="所属楼盘",
)
category = models.CharField(
max_length=20,
choices=ComplexPhotoCategory.choices,
verbose_name="照片类别",
help_text="complex=楼盘图 / layout=户型图 / vr=VR全景 / other=其他",
)
file_key = models.TextField(
verbose_name="文件存储路径",
help_text="R2/S3 路径",
)
thumbnail_key = models.TextField(
blank=True,
default="",
verbose_name="缩略图路径",
)
file_name = models.CharField(
max_length=255,
blank=True,
default="",
verbose_name="原始文件名",
)
file_size = models.IntegerField(
null=True,
blank=True,
verbose_name="文件大小",
help_text="单位bytes",
)
width = models.IntegerField(
null=True,
blank=True,
verbose_name="图片宽度",
help_text="单位px",
)
height = models.IntegerField(
null=True,
blank=True,
verbose_name="图片高度",
help_text="单位px",
)
is_cover = models.BooleanField(
default=False,
verbose_name="是否封面图",
help_text="楼盘封面图(每楼盘唯一)",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序顺序",
help_text="同类别内的排序顺序",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complex_photos",
verbose_name="上传人",
)
class Meta:
db_table = "complex_photos"
verbose_name = "楼盘照片"
verbose_name_plural = "楼盘照片"
indexes = [
models.Index(fields=["complex"], name="idx_complex_photos_complex"),
models.Index(fields=["complex", "category"], name="idx_complex_photos_category"),
]
constraints = [
models.UniqueConstraint(
fields=["complex"],
condition=models.Q(is_cover=True),
name="uq_complex_photos_cover",
),
]
ordering = ["complex_id", "sort_order"]
class ComplexAttachment(UUIDPrimaryKeyModel):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="attachments",
verbose_name="所属楼盘",
)
file_key = models.TextField(
verbose_name="文件存储路径",
help_text="R2/S3 存储路径",
)
file_name = models.CharField(
max_length=255,
verbose_name="原始文件名",
)
file_size = models.IntegerField(
null=True,
blank=True,
verbose_name="文件大小",
help_text="单位bytes",
)
file_type = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="文件类型",
help_text="MIME type",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序顺序",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_complex_attachments",
verbose_name="上传人",
)
class Meta:
db_table = "complex_attachments"
verbose_name = "楼盘附件"
verbose_name_plural = "楼盘附件"
ordering = ["complex_id", "sort_order"]
class ComplexPriceTrend(UUIDPrimaryKeyModel):
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.CASCADE,
related_name="price_trends",
verbose_name="所属楼盘",
)
record_month = models.DateField(
verbose_name="月份",
help_text="统一存为该月1日如 2026-04-01",
)
avg_sale_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="月均售价",
help_text="单位:万元/套",
)
avg_unit_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="月均单价",
help_text="单位:元/m²",
)
transaction_count = models.IntegerField(
null=True,
blank=True,
verbose_name="成交套数",
)
listing_count = models.IntegerField(
null=True,
blank=True,
verbose_name="当月挂牌套数",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
class Meta:
db_table = "complex_price_trends"
verbose_name = "楼盘价格走势"
verbose_name_plural = "楼盘价格走势"
constraints = [
models.UniqueConstraint(
fields=["complex", "record_month"],
name="uq_complex_price_trend_month",
),
]
indexes = [
models.Index(
fields=["complex", "-record_month"],
name="idx_cpx_price_trend_complex",
),
]
ordering = ["complex_id", "-record_month"]

View File

View File

0
apps/complex/tasks.py Normal file
View File

View File

View File

5
apps/complex/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path
app_name = "complex"
urlpatterns: list = []

0
apps/complex/views.py Normal file
View File

0
apps/org/admin.py Normal file
View File

View File

@@ -0,0 +1,57 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('org', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='orgunit',
options={'ordering': ['sort_order', 'name'], 'verbose_name': '组织节点', 'verbose_name_plural': '组织节点'},
),
migrations.AlterModelOptions(
name='staff',
options={'verbose_name': '员工', 'verbose_name_plural': '员工'},
),
migrations.AlterModelOptions(
name='staffaccount',
options={'verbose_name': '员工第三方账号', 'verbose_name_plural': '员工第三方账号'},
),
migrations.AlterModelOptions(
name='staffeducation',
options={'verbose_name': '教育经历', 'verbose_name_plural': '教育经历'},
),
migrations.AlterModelOptions(
name='stafffamilymember',
options={'verbose_name': '家庭成员', 'verbose_name_plural': '家庭成员'},
),
migrations.AlterModelOptions(
name='staffpersonalinfo',
options={'verbose_name': '员工个人信息', 'verbose_name_plural': '员工个人信息'},
),
migrations.AlterModelOptions(
name='staffremark',
options={'verbose_name': '员工备注', 'verbose_name_plural': '员工备注'},
),
migrations.AlterModelOptions(
name='staffrewardpunish',
options={'verbose_name': '奖惩记录', 'verbose_name_plural': '奖惩记录'},
),
migrations.AlterModelOptions(
name='stafftraining',
options={'verbose_name': '培训记录', 'verbose_name_plural': '培训记录'},
),
migrations.AlterModelOptions(
name='stafftransferlog',
options={'verbose_name': '人事异动记录', 'verbose_name_plural': '人事异动记录'},
),
migrations.AlterModelOptions(
name='staffworkexperience',
options={'verbose_name': '工作经历', 'verbose_name_plural': '工作经历'},
),
]

View File

@@ -0,0 +1,626 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('org', '0002_alter_orgunit_options_alter_staff_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='orgunit',
name='address_city',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='所在城市'),
),
migrations.AlterField(
model_name='orgunit',
name='address_detail',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='详细地址'),
),
migrations.AlterField(
model_name='orgunit',
name='address_district',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='所在县区'),
),
migrations.AlterField(
model_name='orgunit',
name='attribute',
field=models.CharField(blank=True, choices=[('direct', '直营'), ('franchise', '加盟')], help_text='direct=直营 / franchise=加盟', max_length=10, null=True, verbose_name='经营属性'),
),
migrations.AlterField(
model_name='orgunit',
name='depth',
field=models.SmallIntegerField(default=0, help_text='根=0最大支持 8 层', verbose_name='节点深度'),
),
migrations.AlterField(
model_name='orgunit',
name='established_at',
field=models.DateField(blank=True, null=True, verbose_name='成立时间'),
),
migrations.AlterField(
model_name='orgunit',
name='ext_end',
field=models.IntegerField(blank=True, null=True, verbose_name='分机号结束'),
),
migrations.AlterField(
model_name='orgunit',
name='ext_start',
field=models.IntegerField(blank=True, null=True, verbose_name='分机号起始'),
),
migrations.AlterField(
model_name='orgunit',
name='is_active',
field=models.BooleanField(default=True, help_text='FALSE=已关闭部门,仍可在筛选中显示', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='orgunit',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=7, help_text='部门定位针 WGS84', max_digits=10, null=True, verbose_name='纬度'),
),
migrations.AlterField(
model_name='orgunit',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=7, help_text='部门定位针 WGS84', max_digits=10, null=True, verbose_name='经度'),
),
migrations.AlterField(
model_name='orgunit',
name='manager',
field=models.ForeignKey(blank=True, help_text='循环依赖Application 层维护', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_org_units', to='org.staff', verbose_name='部门负责人'),
),
migrations.AlterField(
model_name='orgunit',
name='name',
field=models.CharField(help_text='部门/组织名称', max_length=100, verbose_name='部门名称'),
),
migrations.AlterField(
model_name='orgunit',
name='parent',
field=models.ForeignKey(blank=True, help_text='父节点,根节点为 NULL', null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='children', to='org.orgunit', verbose_name='父节点'),
),
migrations.AlterField(
model_name='orgunit',
name='path',
field=models.TextField(help_text='/root_id/.../self_id/,用于子树查询', verbose_name='物化路径'),
),
migrations.AlterField(
model_name='orgunit',
name='phone',
field=models.CharField(blank=True, default='', max_length=30, verbose_name='部门联系电话'),
),
migrations.AlterField(
model_name='orgunit',
name='sort_order',
field=models.IntegerField(default=0, help_text='同级排序', verbose_name='排序顺序'),
),
migrations.AlterField(
model_name='orgunit',
name='type',
field=models.CharField(choices=[('company', '公司'), ('division', '事业部'), ('region', '大区'), ('area', '区域'), ('district', '片区'), ('store', '门店'), ('group', '店组'), ('functional', '职能部门')], help_text='company=公司 / division=事业部 / region=大区 / area=区域 / district=片区 / store=门店 / group=店组 / functional=职能', max_length=20, verbose_name='组织类型'),
),
migrations.AlterField(
model_name='staff',
name='avatar_key',
field=models.TextField(blank=True, default='', help_text='R2/S3 头像路径', verbose_name='头像存储路径'),
),
migrations.AlterField(
model_name='staff',
name='bank_account',
field=models.CharField(blank=True, default='', help_text='内部财务用', max_length=50, verbose_name='银行卡号'),
),
migrations.AlterField(
model_name='staff',
name='bank_name',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='银行名称'),
),
migrations.AlterField(
model_name='staff',
name='business_type',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='业务类型'),
),
migrations.AlterField(
model_name='staff',
name='email',
field=models.EmailField(blank=True, default='', max_length=255, verbose_name='邮箱'),
),
migrations.AlterField(
model_name='staff',
name='employee_no',
field=models.CharField(blank=True, help_text='系统自动生成或手动录入', max_length=30, null=True, unique=True, verbose_name='员工工号'),
),
migrations.AlterField(
model_name='staff',
name='extension',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='分机号'),
),
migrations.AlterField(
model_name='staff',
name='first_joined_at',
field=models.DateField(blank=True, help_text='计算工龄起点', null=True, verbose_name='首次入职日期'),
),
migrations.AlterField(
model_name='staff',
name='industry_exp_years',
field=models.SmallIntegerField(blank=True, help_text='单位:年', null=True, verbose_name='行业经验'),
),
migrations.AlterField(
model_name='staff',
name='is_active',
field=models.BooleanField(default=True, help_text='FALSE 时账号不可登录(联动 auth_user.is_active', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='staff',
name='is_system_admin',
field=models.BooleanField(default=False, help_text='影响权限上限', verbose_name='是否系统管理员'),
),
migrations.AlterField(
model_name='staff',
name='job_category',
field=models.CharField(blank=True, default='', help_text='如「置业顾问」(经纪人判定字段)', max_length=50, verbose_name='职务类别'),
),
migrations.AlterField(
model_name='staff',
name='job_level',
field=models.SmallIntegerField(blank=True, null=True, verbose_name='职级'),
),
migrations.AlterField(
model_name='staff',
name='job_title',
field=models.CharField(blank=True, default='', help_text='如「高级业务员」', max_length=100, verbose_name='职务名称'),
),
migrations.AlterField(
model_name='staff',
name='joined_count',
field=models.SmallIntegerField(default=1, verbose_name='累计入职次数'),
),
migrations.AlterField(
model_name='staff',
name='mentor',
field=models.ForeignKey(blank=True, help_text='带教员工', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mentees', to='org.staff', verbose_name='师傅'),
),
migrations.AlterField(
model_name='staff',
name='name',
field=models.CharField(max_length=50, verbose_name='真实姓名'),
),
migrations.AlterField(
model_name='staff',
name='nickname',
field=models.CharField(blank=True, default='', help_text='通讯录/显示名', max_length=50, verbose_name='昵称'),
),
migrations.AlterField(
model_name='staff',
name='org_unit',
field=models.ForeignKey(help_text='当前所属组织节点(门店或店组)', on_delete=django.db.models.deletion.RESTRICT, related_name='staff_members', to='org.orgunit', verbose_name='所属组织节点'),
),
migrations.AlterField(
model_name='staff',
name='partner_no',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='联号'),
),
migrations.AlterField(
model_name='staff',
name='phone_enc',
field=models.BinaryField(blank=True, help_text='AES-256-GCM 加密手机号', null=True, verbose_name='手机号(加密)'),
),
migrations.AlterField(
model_name='staff',
name='phone_hash',
field=models.CharField(blank=True, db_index=True, help_text='SHA-256 哈希,用于唯一性索引', max_length=64, null=True, verbose_name='手机号哈希'),
),
migrations.AlterField(
model_name='staff',
name='phone_hide',
field=models.BooleanField(default=False, verbose_name='通讯录隐藏手机号'),
),
migrations.AlterField(
model_name='staff',
name='recruit_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recruited_staff', to='org.staff', verbose_name='招聘人'),
),
migrations.AlterField(
model_name='staff',
name='recruit_source',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='招聘来源'),
),
migrations.AlterField(
model_name='staff',
name='referrer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referred_staff', to='org.staff', verbose_name='转介人'),
),
migrations.AlterField(
model_name='staff',
name='rejoined_at',
field=models.DateField(blank=True, null=True, verbose_name='最近复职日期'),
),
migrations.AlterField(
model_name='staff',
name='resigned_at',
field=models.DateField(blank=True, null=True, verbose_name='最近离职日期'),
),
migrations.AlterField(
model_name='staff',
name='role',
field=models.CharField(choices=[('agent', '经纪人'), ('store_manager', '店长'), ('area_manager', '区域经理'), ('admin', '系统管理员'), ('operator', '运营/行政'), ('system', '系统账号')], help_text='agent=经纪人 / store_manager=店长 / area_manager=区域经理 / admin=管理员 / operator=运营 / system=系统账号', max_length=30, verbose_name='系统角色'),
),
migrations.AlterField(
model_name='staff',
name='status',
field=models.CharField(choices=[('active', '在职'), ('probation', '试用'), ('resigned', '离职'), ('frozen', '冻结')], default='active', help_text='active=在职 / probation=试用期 / resigned=已离职 / frozen=账号冻结', max_length=20, verbose_name='员工状态'),
),
migrations.AlterField(
model_name='staff',
name='supervisor',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subordinates', to='org.staff', verbose_name='直属上级'),
),
migrations.AlterField(
model_name='staff',
name='user',
field=models.OneToOneField(blank=True, help_text='Django auth 登录账号', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL, verbose_name='登录账号'),
),
migrations.AlterField(
model_name='staffaccount',
name='account_no',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='账号/手机号'),
),
migrations.AlterField(
model_name='staffaccount',
name='bound_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='绑定时间'),
),
migrations.AlterField(
model_name='staffaccount',
name='is_bound',
field=models.BooleanField(default=False, verbose_name='是否已绑定'),
),
migrations.AlterField(
model_name='staffaccount',
name='is_real_name_match',
field=models.BooleanField(blank=True, help_text='中国网络经纪人专用', null=True, verbose_name='实名信息一致'),
),
migrations.AlterField(
model_name='staffaccount',
name='platform',
field=models.CharField(choices=[('fonrey', '房睿主账号'), ('58anjuke', '58安居客'), ('cnreic', '中国网络经纪人'), ('wechat_mp', '微信公众号')], help_text='fonrey=主账号 / 58anjuke=58安居客 / cnreic=中国网络经纪人 / wechat_mp=微信公众号', max_length=30, verbose_name='平台'),
),
migrations.AlterField(
model_name='staffaccount',
name='staff',
field=models.ForeignKey(help_text='证件信息随员工关联', on_delete=django.db.models.deletion.CASCADE, related_name='external_accounts', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffeducation',
name='degree',
field=models.CharField(blank=True, default='', max_length=30, verbose_name='学位'),
),
migrations.AlterField(
model_name='staffeducation',
name='end_date',
field=models.DateField(blank=True, null=True, verbose_name='结束日期'),
),
migrations.AlterField(
model_name='staffeducation',
name='enrollment_status',
field=models.CharField(blank=True, default='', max_length=30, verbose_name='就读状态'),
),
migrations.AlterField(
model_name='staffeducation',
name='major',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='专业'),
),
migrations.AlterField(
model_name='staffeducation',
name='school',
field=models.CharField(max_length=200, verbose_name='学校'),
),
migrations.AlterField(
model_name='staffeducation',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffeducation',
name='stage',
field=models.CharField(blank=True, default='', max_length=30, verbose_name='教育阶段'),
),
migrations.AlterField(
model_name='staffeducation',
name='start_date',
field=models.DateField(blank=True, null=True, verbose_name='开始日期'),
),
migrations.AlterField(
model_name='stafffamilymember',
name='birthdate',
field=models.DateField(blank=True, null=True, verbose_name='出生日期'),
),
migrations.AlterField(
model_name='stafffamilymember',
name='name',
field=models.CharField(max_length=50, verbose_name='姓名'),
),
migrations.AlterField(
model_name='stafffamilymember',
name='occupation',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='职业'),
),
migrations.AlterField(
model_name='stafffamilymember',
name='phone_enc',
field=models.BinaryField(blank=True, help_text='AES-256-GCM 加密', null=True, verbose_name='电话(加密)'),
),
migrations.AlterField(
model_name='stafffamilymember',
name='relation',
field=models.CharField(max_length=30, verbose_name='称谓'),
),
migrations.AlterField(
model_name='stafffamilymember',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_members', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='stafffamilymember',
name='work_unit',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='工作单位'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='birthdate',
field=models.DateField(blank=True, null=True, verbose_name='出生日期'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='domicile_address',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='户口所在地'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='domicile_type',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='户籍性质'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='education_level',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='最高学历'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='emergency_contact',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='紧急联系人'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='emergency_phone_enc',
field=models.BinaryField(blank=True, null=True, verbose_name='紧急联系人电话(加密)'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='ethnicity',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='民族'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='gender',
field=models.CharField(blank=True, choices=[('male', ''), ('female', ''), ('unknown', '未知')], default='', help_text='male=男 / female=女 / unknown=未知', max_length=10, verbose_name='性别'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='has_children',
field=models.BooleanField(blank=True, null=True, verbose_name='有无子女'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='id_number_enc',
field=models.BinaryField(blank=True, help_text='AES 加密', null=True, verbose_name='证件号码(加密)'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='id_number_hash',
field=models.CharField(blank=True, db_index=True, help_text='SHA-256 哈希,实名认证比对用', max_length=64, null=True, verbose_name='证件号码哈希'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='id_type',
field=models.CharField(blank=True, choices=[('id_card', '身份证'), ('passport', '护照'), ('other', '其他')], default='', help_text='id_card=身份证 / passport=护照 / other=其他', max_length=20, verbose_name='证件类型'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='id_verified',
field=models.BooleanField(default=False, verbose_name='是否实名认证'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='id_verified_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='认证时间'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='marital_status',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='婚姻状况'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='native_place',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='籍贯'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='political_status',
field=models.CharField(blank=True, default='', max_length=20, verbose_name='政治面貌'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='residence_address',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='居住地址'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='staff',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='personal_info', serialize=False, to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='最后更新时间'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_personal_info', to='org.staff', verbose_name='最后修改人'),
),
migrations.AlterField(
model_name='staffpersonalinfo',
name='work_start_date',
field=models.DateField(blank=True, null=True, verbose_name='参加工作时间'),
),
migrations.AlterField(
model_name='staffremark',
name='content',
field=models.TextField(verbose_name='备注内容'),
),
migrations.AlterField(
model_name='staffremark',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_remarks', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='staffremark',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remarks', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffrewardpunish',
name='category',
field=models.CharField(help_text='枚举由 lookup_items 维护org.reward_punish_category', max_length=50, verbose_name='奖惩类别'),
),
migrations.AlterField(
model_name='staffrewardpunish',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_reward_punish', to='org.staff', verbose_name='录入人'),
),
migrations.AlterField(
model_name='staffrewardpunish',
name='name',
field=models.CharField(help_text='与类别联动', max_length=100, verbose_name='奖惩名称'),
),
migrations.AlterField(
model_name='staffrewardpunish',
name='remarks',
field=models.TextField(blank=True, default='', verbose_name='备注'),
),
migrations.AlterField(
model_name='staffrewardpunish',
name='rp_date',
field=models.DateField(verbose_name='奖惩日期'),
),
migrations.AlterField(
model_name='staffrewardpunish',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reward_punish_records', to='org.staff', verbose_name='被奖惩员工'),
),
migrations.AlterField(
model_name='stafftraining',
name='certificate',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='证书'),
),
migrations.AlterField(
model_name='stafftraining',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trainings', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='stafftraining',
name='training_date',
field=models.DateField(blank=True, null=True, verbose_name='培训日期'),
),
migrations.AlterField(
model_name='stafftraining',
name='training_name',
field=models.CharField(max_length=200, verbose_name='培训名称'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='new_value',
field=models.JSONField(blank=True, help_text='结构同 old_value', null=True, verbose_name='变动后值'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='old_value',
field=models.JSONField(blank=True, help_text='格式:{"field": "org_unit_id", "value": "...", "label": "门店A"}', null=True, verbose_name='变动前值'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='operated_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='系统操作时间'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='operator',
field=models.ForeignKey(help_text='必填,异动审计必须记录', on_delete=django.db.models.deletion.RESTRICT, related_name='operated_transfers', to='org.staff', verbose_name='操作人'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='remarks',
field=models.CharField(blank=True, default='', help_text='最多50字', max_length=50, verbose_name='备注'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='transfer_logs', to='org.staff', verbose_name='被操作员工'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='transfer_date',
field=models.DateField(help_text='可以是过去日期', verbose_name='异动生效日期'),
),
migrations.AlterField(
model_name='stafftransferlog',
name='transfer_type',
field=models.CharField(choices=[('onboard', '入职'), ('transfer', '调动'), ('resign', '离职'), ('rejoin', '复职'), ('supervisor_change', '上级变更'), ('role_change', '角色变更'), ('freeze', '冻结账号'), ('unfreeze', '恢复账号')], help_text='onboard=入职 / transfer=调动 / resign=离职 / rejoin=复职 / supervisor_change=上级变动 / role_change=角色变更 / freeze=账号冻结 / unfreeze=账号恢复', max_length=30, verbose_name='异动类型'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='company',
field=models.CharField(max_length=200, verbose_name='公司名称'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='end_date',
field=models.DateField(blank=True, null=True, verbose_name='结束日期'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='job_title',
field=models.CharField(blank=True, default='', max_length=100, verbose_name='职位'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='reason',
field=models.CharField(blank=True, default='', max_length=200, verbose_name='离职原因'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='reference_name',
field=models.CharField(blank=True, default='', max_length=50, verbose_name='证明人姓名'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='reference_phone',
field=models.CharField(blank=True, default='', max_length=30, verbose_name='证明人电话'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='staff',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_experiences', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffworkexperience',
name='start_date',
field=models.DateField(blank=True, null=True, verbose_name='开始日期'),
),
]

View File

@@ -5,8 +5,17 @@ from core.models.base import SoftDeleteModel
class OrgUnit(SoftDeleteModel):
name = models.CharField(max_length=100)
type = models.CharField(max_length=20, choices=OrgUnitType.choices)
name = models.CharField(
max_length=100,
verbose_name="部门名称",
help_text="部门/组织名称",
)
type = models.CharField(
max_length=20,
choices=OrgUnitType.choices,
verbose_name="组织类型",
help_text="company=公司 / division=事业部 / region=大区 / area=区域 / district=片区 / store=门店 / group=店组 / functional=职能",
)
parent = models.ForeignKey(
"self",
null=True,
@@ -14,38 +23,105 @@ class OrgUnit(SoftDeleteModel):
on_delete=models.RESTRICT,
related_name="children",
db_index=True,
verbose_name="父节点",
help_text="父节点,根节点为 NULL",
)
path = models.TextField(
help_text="Materialized path: /root_id/.../self_id/ for subtree queries.",
verbose_name="物化路径",
help_text='/root_id/.../self_id/,用于子树查询',
)
depth = models.SmallIntegerField(
default=0,
verbose_name="节点深度",
help_text="根=0最大支持 8 层",
)
sort_order = models.IntegerField(
default=0,
verbose_name="排序顺序",
help_text="同级排序",
)
depth = models.SmallIntegerField(default=0)
sort_order = models.IntegerField(default=0)
attribute = models.CharField(
max_length=10,
choices=OrgUnitAttribute.choices,
null=True,
blank=True,
verbose_name="经营属性",
help_text="direct=直营 / franchise=加盟",
)
address_city = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="所在城市",
)
address_district = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="所在县区",
)
address_detail = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="详细地址",
)
latitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="纬度",
help_text="部门定位针 WGS84",
)
longitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="经度",
help_text="部门定位针 WGS84",
)
address_city = models.CharField(max_length=50, blank=True, default="")
address_district = models.CharField(max_length=50, blank=True, default="")
address_detail = models.CharField(max_length=200, blank=True, default="")
latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
manager = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="managed_org_units",
verbose_name="部门负责人",
help_text="循环依赖Application 层维护",
)
established_at = models.DateField(
null=True,
blank=True,
verbose_name="成立时间",
)
phone = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="部门联系电话",
)
ext_start = models.IntegerField(
null=True,
blank=True,
verbose_name="分机号起始",
)
ext_end = models.IntegerField(
null=True,
blank=True,
verbose_name="分机号结束",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="FALSE=已关闭部门,仍可在筛选中显示",
)
established_at = models.DateField(null=True, blank=True)
phone = models.CharField(max_length=30, blank=True, default="")
ext_start = models.IntegerField(null=True, blank=True)
ext_end = models.IntegerField(null=True, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "org_units"
verbose_name = "组织节点"
verbose_name_plural = "组织节点"
indexes = [
models.Index(fields=["parent"], name="idx_org_units_parent"),
models.Index(fields=["type"], name="idx_org_units_type"),

View File

@@ -11,6 +11,8 @@ class Staff(SoftDeleteModel):
on_delete=models.RESTRICT,
related_name="staff_members",
db_index=True,
verbose_name="所属组织节点",
help_text="当前所属组织节点(门店或店组)",
)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
@@ -18,77 +20,201 @@ class Staff(SoftDeleteModel):
blank=True,
on_delete=models.SET_NULL,
related_name="staff_profile",
verbose_name="登录账号",
help_text="Django auth 登录账号",
)
name = models.CharField(
max_length=50,
verbose_name="真实姓名",
)
nickname = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="昵称",
help_text="通讯录/显示名",
)
employee_no = models.CharField(
max_length=30,
null=True,
blank=True,
unique=True,
verbose_name="员工工号",
help_text="系统自动生成或手动录入",
)
role = models.CharField(
max_length=30,
choices=StaffRole.choices,
verbose_name="系统角色",
help_text="agent=经纪人 / store_manager=店长 / area_manager=区域经理 / admin=管理员 / operator=运营 / system=系统账号",
)
job_title = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="职务名称",
help_text='如「高级业务员」',
)
name = models.CharField(max_length=50)
nickname = models.CharField(max_length=50, blank=True, default="")
employee_no = models.CharField(max_length=30, null=True, blank=True, unique=True)
role = models.CharField(max_length=30, choices=StaffRole.choices)
job_title = models.CharField(max_length=100, blank=True, default="")
job_category = models.CharField(
max_length=50,
blank=True,
default="",
help_text="Job classification (e.g. '置业顾问' = agent qualification flag).",
verbose_name="职务类别",
help_text='如「置业顾问」(经纪人判定字段)',
)
job_level = models.SmallIntegerField(
null=True,
blank=True,
verbose_name="职级",
)
job_level = models.SmallIntegerField(null=True, blank=True)
supervisor = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="subordinates",
verbose_name="直属上级",
)
status = models.CharField(
max_length=20,
choices=StaffStatus.choices,
default=StaffStatus.ACTIVE,
verbose_name="员工状态",
help_text="active=在职 / probation=试用期 / resigned=已离职 / frozen=账号冻结",
)
phone_enc = models.BinaryField(
null=True,
blank=True,
help_text="AES-256-GCM encrypted phone (DATA_MODEL_ORG §3.2).",
verbose_name="手机号(加密)",
help_text="AES-256-GCM 加密手机号",
)
phone_hash = models.CharField(
max_length=64,
null=True,
blank=True,
db_index=True,
verbose_name="手机号哈希",
help_text="SHA-256 哈希,用于唯一性索引",
)
phone_hide = models.BooleanField(
default=False,
verbose_name="通讯录隐藏手机号",
)
email = models.EmailField(
max_length=255,
blank=True,
default="",
verbose_name="邮箱",
)
extension = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="分机号",
)
avatar_key = models.TextField(
blank=True,
default="",
verbose_name="头像存储路径",
help_text="R2/S3 头像路径",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="FALSE 时账号不可登录(联动 auth_user.is_active",
)
is_system_admin = models.BooleanField(
default=False,
verbose_name="是否系统管理员",
help_text="影响权限上限",
)
first_joined_at = models.DateField(
null=True,
blank=True,
verbose_name="首次入职日期",
help_text="计算工龄起点",
)
rejoined_at = models.DateField(
null=True,
blank=True,
verbose_name="最近复职日期",
)
resigned_at = models.DateField(
null=True,
blank=True,
verbose_name="最近离职日期",
)
joined_count = models.SmallIntegerField(
default=1,
verbose_name="累计入职次数",
)
industry_exp_years = models.SmallIntegerField(
null=True,
blank=True,
verbose_name="行业经验",
help_text="单位:年",
)
phone_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
phone_hide = models.BooleanField(default=False)
email = models.EmailField(max_length=255, blank=True, default="")
extension = models.CharField(max_length=20, blank=True, default="")
avatar_key = models.TextField(blank=True, default="")
is_active = models.BooleanField(default=True)
is_system_admin = models.BooleanField(default=False)
first_joined_at = models.DateField(null=True, blank=True)
rejoined_at = models.DateField(null=True, blank=True)
resigned_at = models.DateField(null=True, blank=True)
joined_count = models.SmallIntegerField(default=1)
industry_exp_years = models.SmallIntegerField(null=True, blank=True)
mentor = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="mentees",
verbose_name="师傅",
help_text="带教员工",
)
business_type = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="业务类型",
)
bank_name = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="银行名称",
)
bank_account = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="银行卡号",
help_text="内部财务用",
)
partner_no = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="联号",
)
business_type = models.CharField(max_length=50, blank=True, default="")
bank_name = models.CharField(max_length=100, blank=True, default="")
bank_account = models.CharField(max_length=50, blank=True, default="")
partner_no = models.CharField(max_length=50, blank=True, default="")
recruit_by = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="recruited_staff",
verbose_name="招聘人",
)
recruit_source = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="招聘来源",
)
recruit_source = models.CharField(max_length=50, blank=True, default="")
referrer = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="referred_staff",
verbose_name="转介人",
)
class Meta:
db_table = "staff"
verbose_name = "员工"
verbose_name_plural = "员工"
indexes = [
models.Index(fields=["org_unit"], name="idx_staff_org_unit"),
models.Index(fields=["supervisor"], name="idx_staff_supervisor"),
@@ -105,34 +231,135 @@ class StaffPersonalInfo(models.Model):
on_delete=models.CASCADE,
related_name="personal_info",
primary_key=True,
verbose_name="所属员工",
)
gender = models.CharField(
max_length=10,
choices=StaffGender.choices,
blank=True,
default="",
verbose_name="性别",
help_text="male=男 / female=女 / unknown=未知",
)
id_type = models.CharField(
max_length=20,
choices=StaffIdType.choices,
blank=True,
default="",
verbose_name="证件类型",
help_text="id_card=身份证 / passport=护照 / other=其他",
)
id_number_enc = models.BinaryField(
null=True,
blank=True,
verbose_name="证件号码(加密)",
help_text="AES 加密",
)
id_number_hash = models.CharField(
max_length=64,
null=True,
blank=True,
db_index=True,
verbose_name="证件号码哈希",
help_text="SHA-256 哈希,实名认证比对用",
)
id_verified = models.BooleanField(
default=False,
verbose_name="是否实名认证",
)
id_verified_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="认证时间",
)
birthdate = models.DateField(
null=True,
blank=True,
verbose_name="出生日期",
)
native_place = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="籍贯",
)
domicile_type = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="户籍性质",
)
marital_status = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="婚姻状况",
)
political_status = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="政治面貌",
)
has_children = models.BooleanField(
null=True,
blank=True,
verbose_name="有无子女",
)
education_level = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="最高学历",
)
ethnicity = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="民族",
)
domicile_address = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="户口所在地",
)
residence_address = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="居住地址",
)
work_start_date = models.DateField(
null=True,
blank=True,
verbose_name="参加工作时间",
)
emergency_contact = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="紧急联系人",
)
emergency_phone_enc = models.BinaryField(
null=True,
blank=True,
verbose_name="紧急联系人电话(加密)",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="最后更新时间",
)
gender = models.CharField(max_length=10, choices=StaffGender.choices, blank=True, default="")
id_type = models.CharField(max_length=20, choices=StaffIdType.choices, blank=True, default="")
id_number_enc = models.BinaryField(null=True, blank=True)
id_number_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
id_verified = models.BooleanField(default=False)
id_verified_at = models.DateTimeField(null=True, blank=True)
birthdate = models.DateField(null=True, blank=True)
native_place = models.CharField(max_length=100, blank=True, default="")
domicile_type = models.CharField(max_length=20, blank=True, default="")
marital_status = models.CharField(max_length=20, blank=True, default="")
political_status = models.CharField(max_length=20, blank=True, default="")
has_children = models.BooleanField(null=True, blank=True)
education_level = models.CharField(max_length=20, blank=True, default="")
ethnicity = models.CharField(max_length=20, blank=True, default="")
domicile_address = models.CharField(max_length=200, blank=True, default="")
residence_address = models.CharField(max_length=200, blank=True, default="")
work_start_date = models.DateField(null=True, blank=True)
emergency_contact = models.CharField(max_length=50, blank=True, default="")
emergency_phone_enc = models.BinaryField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_personal_info",
verbose_name="最后修改人",
)
class Meta:
db_table = "staff_personal_info"
verbose_name = "员工个人信息"
verbose_name_plural = "员工个人信息"

View File

@@ -9,21 +9,53 @@ class StaffTransferLog(TimeStampedModel):
"org.Staff",
on_delete=models.RESTRICT,
related_name="transfer_logs",
verbose_name="被操作员工",
)
transfer_type = models.CharField(
max_length=30,
choices=StaffTransferType.choices,
verbose_name="异动类型",
help_text="onboard=入职 / transfer=调动 / resign=离职 / rejoin=复职 / supervisor_change=上级变动 / role_change=角色变更 / freeze=账号冻结 / unfreeze=账号恢复",
)
old_value = models.JSONField(
null=True,
blank=True,
verbose_name="变动前值",
help_text='格式:{"field": "org_unit_id", "value": "...", "label": "门店A"}',
)
new_value = models.JSONField(
null=True,
blank=True,
verbose_name="变动后值",
help_text="结构同 old_value",
)
transfer_date = models.DateField(
verbose_name="异动生效日期",
help_text="可以是过去日期",
)
remarks = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="备注",
help_text="最多50字",
)
transfer_type = models.CharField(max_length=30, choices=StaffTransferType.choices)
old_value = models.JSONField(null=True, blank=True)
new_value = models.JSONField(null=True, blank=True)
transfer_date = models.DateField()
remarks = models.CharField(max_length=50, blank=True, default="")
operator = models.ForeignKey(
"org.Staff",
on_delete=models.RESTRICT,
related_name="operated_transfers",
verbose_name="操作人",
help_text="必填,异动审计必须记录",
)
operated_at = models.DateTimeField(
auto_now_add=True,
verbose_name="系统操作时间",
)
operated_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "staff_transfer_logs"
verbose_name = "人事异动记录"
verbose_name_plural = "人事异动记录"
indexes = [
models.Index(fields=["staff", "-transfer_date"], name="idx_transfer_logs_staff"),
models.Index(fields=["transfer_type", "-operated_at"], name="idx_transfer_logs_type"),
@@ -36,24 +68,39 @@ class StaffRewardPunish(SoftDeleteModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="reward_punish_records",
verbose_name="被奖惩员工",
)
rp_date = models.DateField(
verbose_name="奖惩日期",
)
rp_date = models.DateField()
category = models.CharField(
max_length=50,
help_text="Configurable lookup_items domain: org.reward_punish_category.",
verbose_name="奖惩类别",
help_text="枚举由 lookup_items 维护org.reward_punish_category",
)
name = models.CharField(
max_length=100,
verbose_name="奖惩名称",
help_text="与类别联动",
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="备注",
)
name = models.CharField(max_length=100)
remarks = models.TextField(blank=True, default="")
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_reward_punish",
verbose_name="录入人",
)
class Meta:
db_table = "staff_reward_punish"
verbose_name = "奖惩记录"
verbose_name_plural = "奖惩记录"
class StaffAccount(TimeStampedModel):
@@ -61,15 +108,41 @@ class StaffAccount(TimeStampedModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="external_accounts",
verbose_name="所属员工",
help_text="证件信息随员工关联",
)
platform = models.CharField(
max_length=30,
choices=StaffAccountPlatform.choices,
verbose_name="平台",
help_text="fonrey=主账号 / 58anjuke=58安居客 / cnreic=中国网络经纪人 / wechat_mp=微信公众号",
)
account_no = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="账号/手机号",
)
is_real_name_match = models.BooleanField(
null=True,
blank=True,
verbose_name="实名信息一致",
help_text="中国网络经纪人专用",
)
is_bound = models.BooleanField(
default=False,
verbose_name="是否已绑定",
)
bound_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="绑定时间",
)
platform = models.CharField(max_length=30, choices=StaffAccountPlatform.choices)
account_no = models.CharField(max_length=100, blank=True, default="")
is_real_name_match = models.BooleanField(null=True, blank=True)
is_bound = models.BooleanField(default=False)
bound_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "staff_accounts"
verbose_name = "员工第三方账号"
verbose_name_plural = "员工第三方账号"
constraints = [
models.UniqueConstraint(
fields=["staff", "platform"],
@@ -83,17 +156,51 @@ class StaffWorkExperience(TimeStampedModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="work_experiences",
verbose_name="所属员工",
)
company = models.CharField(
max_length=200,
verbose_name="公司名称",
)
job_title = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="职位",
)
start_date = models.DateField(
null=True,
blank=True,
verbose_name="开始日期",
)
end_date = models.DateField(
null=True,
blank=True,
verbose_name="结束日期",
)
reason = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="离职原因",
)
reference_name = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="证明人姓名",
)
reference_phone = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="证明人电话",
)
company = models.CharField(max_length=200)
job_title = models.CharField(max_length=100, blank=True, default="")
start_date = models.DateField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True)
reason = models.CharField(max_length=200, blank=True, default="")
reference_name = models.CharField(max_length=50, blank=True, default="")
reference_phone = models.CharField(max_length=30, blank=True, default="")
class Meta:
db_table = "staff_work_experiences"
verbose_name = "工作经历"
verbose_name_plural = "工作经历"
class StaffEducation(TimeStampedModel):
@@ -101,17 +208,51 @@ class StaffEducation(TimeStampedModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="educations",
verbose_name="所属员工",
)
stage = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="教育阶段",
)
school = models.CharField(
max_length=200,
verbose_name="学校",
)
major = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="专业",
)
start_date = models.DateField(
null=True,
blank=True,
verbose_name="开始日期",
)
end_date = models.DateField(
null=True,
blank=True,
verbose_name="结束日期",
)
enrollment_status = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="就读状态",
)
degree = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="学位",
)
stage = models.CharField(max_length=30, blank=True, default="")
school = models.CharField(max_length=200)
major = models.CharField(max_length=100, blank=True, default="")
start_date = models.DateField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True)
enrollment_status = models.CharField(max_length=30, blank=True, default="")
degree = models.CharField(max_length=30, blank=True, default="")
class Meta:
db_table = "staff_educations"
verbose_name = "教育经历"
verbose_name_plural = "教育经历"
class StaffTraining(TimeStampedModel):
@@ -119,13 +260,28 @@ class StaffTraining(TimeStampedModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="trainings",
verbose_name="所属员工",
)
training_name = models.CharField(
max_length=200,
verbose_name="培训名称",
)
training_date = models.DateField(
null=True,
blank=True,
verbose_name="培训日期",
)
certificate = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="证书",
)
training_name = models.CharField(max_length=200)
training_date = models.DateField(null=True, blank=True)
certificate = models.CharField(max_length=200, blank=True, default="")
class Meta:
db_table = "staff_trainings"
verbose_name = "培训记录"
verbose_name_plural = "培训记录"
class StaffFamilyMember(TimeStampedModel):
@@ -133,16 +289,44 @@ class StaffFamilyMember(TimeStampedModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="family_members",
verbose_name="所属员工",
)
relation = models.CharField(
max_length=30,
verbose_name="称谓",
)
name = models.CharField(
max_length=50,
verbose_name="姓名",
)
birthdate = models.DateField(
null=True,
blank=True,
verbose_name="出生日期",
)
occupation = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="职业",
)
work_unit = models.CharField(
max_length=200,
blank=True,
default="",
verbose_name="工作单位",
)
phone_enc = models.BinaryField(
null=True,
blank=True,
verbose_name="电话(加密)",
help_text="AES-256-GCM 加密",
)
relation = models.CharField(max_length=30)
name = models.CharField(max_length=50)
birthdate = models.DateField(null=True, blank=True)
occupation = models.CharField(max_length=100, blank=True, default="")
work_unit = models.CharField(max_length=200, blank=True, default="")
phone_enc = models.BinaryField(null=True, blank=True)
class Meta:
db_table = "staff_family_members"
verbose_name = "家庭成员"
verbose_name_plural = "家庭成员"
class StaffRemark(SoftDeleteModel):
@@ -150,15 +334,21 @@ class StaffRemark(SoftDeleteModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="remarks",
verbose_name="所属员工",
)
content = models.TextField(
verbose_name="备注内容",
)
content = models.TextField()
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_remarks",
verbose_name="创建人",
)
class Meta:
db_table = "staff_remarks"
verbose_name = "员工备注"
verbose_name_plural = "员工备注"

0
apps/permission/admin.py Normal file
View File

View File

@@ -0,0 +1,41 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fonrey_permission', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='permissionchangelog',
options={'ordering': ['-operated_at'], 'verbose_name': '权限变更流水', 'verbose_name_plural': '权限变更流水'},
),
migrations.AlterModelOptions(
name='permissiondef',
options={'verbose_name': '权限定义', 'verbose_name_plural': '权限定义'},
),
migrations.AlterModelOptions(
name='role',
options={'verbose_name': '角色', 'verbose_name_plural': '角色'},
),
migrations.AlterModelOptions(
name='rolepermission',
options={'verbose_name': '角色权限', 'verbose_name_plural': '角色权限'},
),
migrations.AlterModelOptions(
name='staffdatascope',
options={'verbose_name': '员工数据范围', 'verbose_name_plural': '员工数据范围'},
),
migrations.AlterModelOptions(
name='staffpermissionoverride',
options={'verbose_name': '个人权限覆盖', 'verbose_name_plural': '个人权限覆盖'},
),
migrations.AlterModelOptions(
name='staffrole',
options={'verbose_name': '员工角色', 'verbose_name_plural': '员工角色'},
),
]

View File

@@ -0,0 +1,336 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('org', '0003_alter_orgunit_address_city_and_more'),
('fonrey_permission', '0002_alter_permissionchangelog_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='permissionchangelog',
name='action',
field=models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除'), ('assign', '分配'), ('revoke', '撤销')], help_text='create / update / delete / assign / revoke', max_length=20, verbose_name='操作动作'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='new_value',
field=models.JSONField(blank=True, null=True, verbose_name='变更后快照'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='old_value',
field=models.JSONField(blank=True, null=True, verbose_name='变更前快照'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='operated_at',
field=models.DateTimeField(auto_now_add=True, help_text='append-only 流水,分区键', verbose_name='操作时间'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='operator',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='permission_changes_operated', to='org.staff', verbose_name='操作人'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='operator_ip',
field=models.GenericIPAddressField(blank=True, null=True, verbose_name='操作来源 IP'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='permission_code',
field=models.CharField(blank=True, default='', help_text='用 code 而非 FK避免 PermissionDef 删除后日志丢失', max_length=150, verbose_name='权限编码'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='reason',
field=models.TextField(blank=True, default='', help_text='批量设置角色等场景强制填写', verbose_name='操作原因'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='change_logs', to='fonrey_permission.role', verbose_name='被影响角色'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='staff',
field=models.ForeignKey(blank=True, help_text='target 是 staff_role/staff_override/staff_scope 时必填', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_change_logs_affecting', to='org.staff', verbose_name='被影响员工'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='target_id',
field=models.UUIDField(verbose_name='变更对象 ID'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='target_type',
field=models.CharField(choices=[('role', '角色'), ('role_permission', '角色权限'), ('staff_role', '员工角色'), ('staff_override', '员工权限覆盖'), ('staff_scope', '员工数据范围')], help_text='role / role_permission / staff_role / staff_override / staff_scope', max_length=30, verbose_name='变更对象类型'),
),
migrations.AlterField(
model_name='permissionchangelog',
name='user_agent',
field=models.TextField(blank=True, default='', verbose_name='操作终端 UA'),
),
migrations.AlterField(
model_name='permissiondef',
name='code',
field=models.CharField(help_text='规则:{module}.{sub_module}.{action}[.{qualifier}]', max_length=150, unique=True, verbose_name='权限编码'),
),
migrations.AlterField(
model_name='permissiondef',
name='default_value',
field=models.JSONField(default=dict, help_text='系统最小默认值,格式 {"v": <value>}', verbose_name='默认值'),
),
migrations.AlterField(
model_name='permissiondef',
name='description',
field=models.TextField(blank=True, default='', verbose_name='权限作用描述'),
),
migrations.AlterField(
model_name='permissiondef',
name='group_name',
field=models.CharField(help_text='如「私客基础权限」「联系人基础权限」', max_length=100, verbose_name='分组标题'),
),
migrations.AlterField(
model_name='permissiondef',
name='integer_max',
field=models.IntegerField(blank=True, help_text='仅 INTEGER 类型有效NULL=无上限(业务上 0 通常代表不限制)', null=True, verbose_name='最大值'),
),
migrations.AlterField(
model_name='permissiondef',
name='integer_min',
field=models.IntegerField(blank=True, help_text='仅 INTEGER 类型有效', null=True, verbose_name='最小值'),
),
migrations.AlterField(
model_name='permissiondef',
name='is_active',
field=models.BooleanField(default=True, help_text='下线权限项置 FALSE历史记录保留', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='permissiondef',
name='is_deprecated',
field=models.BooleanField(default=False, help_text='不再推荐使用但保持兼容', verbose_name='是否废弃'),
),
migrations.AlterField(
model_name='permissiondef',
name='max_allowed_categories',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), blank=True, default=list, help_text='允许配置此权限的角色类别列表,空数组=所有类别均可', size=None, verbose_name='可配置角色类别'),
),
migrations.AlterField(
model_name='permissiondef',
name='module',
field=models.CharField(choices=[('home', '首页'), ('property', '房源'), ('new_house', '新房'), ('client', '客源'), ('transaction', '交易'), ('data', '数据'), ('marketing', '营销'), ('hr', '人事OA'), ('contract', '合同'), ('trinet', '三网'), ('system', '系统'), ('mobile', '移动端'), ('smart_store', '智能门店'), ('recharge', '在线充值')], help_text='home/property/new_house/client/transaction/data/marketing/hr/contract/trinet/system/mobile/smart_store/recharge', max_length=50, verbose_name='一级模块'),
),
migrations.AlterField(
model_name='permissiondef',
name='name',
field=models.CharField(max_length=200, verbose_name='显示名称'),
),
migrations.AlterField(
model_name='permissiondef',
name='scope_choices',
field=models.JSONField(blank=True, default=list, help_text='仅 SCOPE 类型有效,可选枚举 code 列表,如 ["none","self","store","company"]', verbose_name='可选范围'),
),
migrations.AlterField(
model_name='permissiondef',
name='sort_order',
field=models.PositiveIntegerField(default=0, help_text='分组内排序', verbose_name='排序顺序'),
),
migrations.AlterField(
model_name='permissiondef',
name='sub_module',
field=models.CharField(blank=True, default='', help_text='如「二手&租赁」「商圈精耕」', max_length=50, verbose_name='二级模块'),
),
migrations.AlterField(
model_name='permissiondef',
name='value_type',
field=models.CharField(choices=[('boolean', '开关型'), ('scope', '范围型'), ('integer', '数值型')], help_text='BOOLEAN=开关型 / SCOPE=范围型 / INTEGER=数值型', max_length=20, verbose_name='权限值类型'),
),
migrations.AlterField(
model_name='permissiondef',
name='version',
field=models.PositiveIntegerField(default=1, help_text='变更时递增,用于缓存失效', verbose_name='定义版本'),
),
migrations.AlterField(
model_name='role',
name='category',
field=models.CharField(choices=[('agent', '置业顾问'), ('store_manager', '店管'), ('director', '总经'), ('operator', '运营/行政'), ('custom', '自定义')], help_text='agent=置业顾问 / store_manager=店管 / director=总经 / operator=运营 / custom=自定义', max_length=30, verbose_name='角色类别'),
),
migrations.AlterField(
model_name='role',
name='created_by',
field=models.ForeignKey(blank=True, help_text='角色类别只能由创建者修改', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_roles_created', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='role',
name='description',
field=models.TextField(blank=True, default='', verbose_name='角色描述'),
),
migrations.AlterField(
model_name='role',
name='is_active',
field=models.BooleanField(default=True, help_text='FALSE=禁用(员工无法继承该角色权限)', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='role',
name='is_system_builtin',
field=models.BooleanField(default=False, help_text='如「最大权限角色」,不可删除、不可改名', verbose_name='是否系统内置'),
),
migrations.AlterField(
model_name='role',
name='name',
field=models.CharField(max_length=100, verbose_name='角色名称'),
),
migrations.AlterField(
model_name='role',
name='template_role',
field=models.ForeignKey(blank=True, help_text='PRD「引用该角色配置」列', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_roles', to='fonrey_permission.role', verbose_name='权限模板来源'),
),
migrations.AlterField(
model_name='role',
name='updated_by',
field=models.ForeignKey(blank=True, help_text='权限管理审计用', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_roles_updated', to='org.staff', verbose_name='最后修改人'),
),
migrations.AlterField(
model_name='rolepermission',
name='permission_def',
field=models.ForeignKey(help_text='RESTRICT 防止删除仍被引用的权限项', on_delete=django.db.models.deletion.PROTECT, related_name='role_assignments', to='fonrey_permission.permissiondef', verbose_name='权限定义'),
),
migrations.AlterField(
model_name='rolepermission',
name='role',
field=models.ForeignKey(help_text='稀疏存储:角色删除时级联清理权限值', on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='fonrey_permission.role', verbose_name='所属角色'),
),
migrations.AlterField(
model_name='rolepermission',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='role_permissions_updated', to='org.staff', verbose_name='最后修改人'),
),
migrations.AlterField(
model_name='rolepermission',
name='value',
field=models.JSONField(help_text='统一格式 {"v": <value>}', verbose_name='权限值'),
),
migrations.AlterField(
model_name='staffdatascope',
name='expires_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='临时授权失效时间'),
),
migrations.AlterField(
model_name='staffdatascope',
name='granted_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='授权时间'),
),
migrations.AlterField(
model_name='staffdatascope',
name='granted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='data_scopes_granted', to='org.staff', verbose_name='授权操作人'),
),
migrations.AlterField(
model_name='staffdatascope',
name='is_readable',
field=models.BooleanField(default=True, verbose_name='可读'),
),
migrations.AlterField(
model_name='staffdatascope',
name='is_writable',
field=models.BooleanField(default=False, help_text='默认只读', verbose_name='可写'),
),
migrations.AlterField(
model_name='staffdatascope',
name='org_unit',
field=models.ForeignKey(blank=True, help_text='scope_type=custom_unit 时必填,其他类型为 NULL', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='data_scope_grants', to='org.orgunit', verbose_name='组织节点'),
),
migrations.AlterField(
model_name='staffdatascope',
name='reason',
field=models.TextField(blank=True, default='', verbose_name='授予原因'),
),
migrations.AlterField(
model_name='staffdatascope',
name='scope_type',
field=models.CharField(choices=[('self', '本人'), ('group', '本组'), ('store', '本门店'), ('area', '本区域'), ('region', '本大区'), ('company', '全公司'), ('custom_unit', '自定义组织单元')], help_text='self=本人 / group=本组 / store=本门店 / area=本区域 / region=本大区 / company=全公司 / custom_unit=指定节点', max_length=20, verbose_name='范围类型'),
),
migrations.AlterField(
model_name='staffdatascope',
name='staff',
field=models.ForeignKey(help_text='员工删除时级联删除范围记录', on_delete=django.db.models.deletion.CASCADE, related_name='data_scopes', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffpermissionoverride',
name='modified_at',
field=models.DateTimeField(auto_now=True, verbose_name='最近修改时间'),
),
migrations.AlterField(
model_name='staffpermissionoverride',
name='modified_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_overrides_modified', to='org.staff', verbose_name='修改人'),
),
migrations.AlterField(
model_name='staffpermissionoverride',
name='override_mode',
field=models.CharField(choices=[('replace', '覆盖'), ('restrict', '限制'), ('grant', '授予')], default='replace', help_text='REPLACE=替换合并值 / RESTRICT=限制上限 / GRANT=仅扩展', max_length=10, verbose_name='覆盖模式'),
),
migrations.AlterField(
model_name='staffpermissionoverride',
name='permission_def',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='staff_overrides', to='fonrey_permission.permissiondef', verbose_name='被覆盖权限项'),
),
migrations.AlterField(
model_name='staffpermissionoverride',
name='reason',
field=models.TextField(blank=True, default='', help_text='管理员备注,建议强制填写以便审计', verbose_name='备注'),
),
migrations.AlterField(
model_name='staffpermissionoverride',
name='staff',
field=models.ForeignKey(help_text='员工删除时级联删除覆盖记录', on_delete=django.db.models.deletion.CASCADE, related_name='permission_overrides', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffpermissionoverride',
name='value',
field=models.JSONField(help_text='统一格式 {"v": <value>}', verbose_name='个人权限值'),
),
migrations.AlterField(
model_name='staffrole',
name='assigned_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='分配时间'),
),
migrations.AlterField(
model_name='staffrole',
name='assigned_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_role_assignments_made', to='org.staff', verbose_name='分配操作人'),
),
migrations.AlterField(
model_name='staffrole',
name='is_primary',
field=models.BooleanField(default=False, help_text='每个员工有且仅有一个主角色', verbose_name='是否主角色'),
),
migrations.AlterField(
model_name='staffrole',
name='role',
field=models.ForeignKey(help_text='角色被员工引用时禁止删除', on_delete=django.db.models.deletion.PROTECT, related_name='staff_links', to='fonrey_permission.role', verbose_name='角色'),
),
migrations.AlterField(
model_name='staffrole',
name='staff',
field=models.ForeignKey(help_text='员工删除时级联删除角色关联', on_delete=django.db.models.deletion.CASCADE, related_name='staff_roles', to='org.staff', verbose_name='所属员工'),
),
migrations.AlterField(
model_name='staffrole',
name='valid_from',
field=models.DateField(blank=True, help_text='预留未来「定时生效」功能', null=True, verbose_name='生效日'),
),
migrations.AlterField(
model_name='staffrole',
name='valid_until',
field=models.DateField(blank=True, null=True, verbose_name='失效日'),
),
]

View File

@@ -6,29 +6,100 @@ from core.models.base import TimeStampedModel
class PermissionDef(TimeStampedModel):
code = models.CharField(max_length=150, unique=True)
module = models.CharField(max_length=50, choices=PermissionModule.choices)
sub_module = models.CharField(max_length=50, blank=True, default="")
group_name = models.CharField(max_length=100)
name = models.CharField(max_length=200)
description = models.TextField(blank=True, default="")
value_type = models.CharField(max_length=20, choices=PermissionValueType.choices)
scope_choices = models.JSONField(default=list, blank=True)
integer_min = models.IntegerField(null=True, blank=True)
integer_max = models.IntegerField(null=True, blank=True)
default_value = models.JSONField(default=dict)
code = models.CharField(
max_length=150,
unique=True,
verbose_name="权限编码",
help_text='规则:{module}.{sub_module}.{action}[.{qualifier}]',
)
module = models.CharField(
max_length=50,
choices=PermissionModule.choices,
verbose_name="一级模块",
help_text="home/property/new_house/client/transaction/data/marketing/hr/contract/trinet/system/mobile/smart_store/recharge",
)
sub_module = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="二级模块",
help_text='如「二手&租赁」「商圈精耕」',
)
group_name = models.CharField(
max_length=100,
verbose_name="分组标题",
help_text='如「私客基础权限」「联系人基础权限」',
)
name = models.CharField(
max_length=200,
verbose_name="显示名称",
)
description = models.TextField(
blank=True,
default="",
verbose_name="权限作用描述",
)
value_type = models.CharField(
max_length=20,
choices=PermissionValueType.choices,
verbose_name="权限值类型",
help_text="BOOLEAN=开关型 / SCOPE=范围型 / INTEGER=数值型",
)
scope_choices = models.JSONField(
default=list,
blank=True,
verbose_name="可选范围",
help_text='仅 SCOPE 类型有效,可选枚举 code 列表,如 ["none","self","store","company"]',
)
integer_min = models.IntegerField(
null=True,
blank=True,
verbose_name="最小值",
help_text="仅 INTEGER 类型有效",
)
integer_max = models.IntegerField(
null=True,
blank=True,
verbose_name="最大值",
help_text="仅 INTEGER 类型有效NULL=无上限(业务上 0 通常代表不限制)",
)
default_value = models.JSONField(
default=dict,
verbose_name="默认值",
help_text='系统最小默认值,格式 {"v": <value>}',
)
max_allowed_categories = ArrayField(
models.CharField(max_length=50),
default=list,
blank=True,
verbose_name="可配置角色类别",
help_text="允许配置此权限的角色类别列表,空数组=所有类别均可",
)
sort_order = models.PositiveIntegerField(
default=0,
verbose_name="排序顺序",
help_text="分组内排序",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="下线权限项置 FALSE历史记录保留",
)
is_deprecated = models.BooleanField(
default=False,
verbose_name="是否废弃",
help_text="不再推荐使用但保持兼容",
)
version = models.PositiveIntegerField(
default=1,
verbose_name="定义版本",
help_text="变更时递增,用于缓存失效",
)
sort_order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
is_deprecated = models.BooleanField(default=False)
version = models.PositiveIntegerField(default=1)
class Meta:
db_table = "permission_defs"
verbose_name = "权限定义"
verbose_name_plural = "权限定义"
indexes = [
models.Index(
fields=["module", "sub_module", "sort_order"],

View File

@@ -5,24 +5,48 @@ from core.models.base import SoftDeleteModel, TimeStampedModel
class Role(SoftDeleteModel):
name = models.CharField(max_length=100)
category = models.CharField(max_length=30, choices=PermissionRoleCategory.choices)
description = models.TextField(blank=True, default="")
name = models.CharField(
max_length=100,
verbose_name="角色名称",
)
category = models.CharField(
max_length=30,
choices=PermissionRoleCategory.choices,
verbose_name="角色类别",
help_text="agent=置业顾问 / store_manager=店管 / director=总经 / operator=运营 / custom=自定义",
)
description = models.TextField(
blank=True,
default="",
verbose_name="角色描述",
)
template_role = models.ForeignKey(
"fonrey_permission.Role",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="derived_roles",
verbose_name="权限模板来源",
help_text='PRD「引用该角色配置」列',
)
is_system_builtin = models.BooleanField(
default=False,
verbose_name="是否系统内置",
help_text='如「最大权限角色」,不可删除、不可改名',
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="FALSE=禁用(员工无法继承该角色权限)",
)
is_system_builtin = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="permission_roles_created",
verbose_name="创建人",
help_text="角色类别只能由创建者修改",
)
updated_by = models.ForeignKey(
"org.Staff",
@@ -30,10 +54,14 @@ class Role(SoftDeleteModel):
blank=True,
on_delete=models.SET_NULL,
related_name="permission_roles_updated",
verbose_name="最后修改人",
help_text="权限管理审计用",
)
class Meta:
db_table = "roles"
verbose_name = "角色"
verbose_name_plural = "角色"
constraints = [
models.UniqueConstraint(
fields=["name"],
@@ -59,23 +87,33 @@ class RolePermission(TimeStampedModel):
"fonrey_permission.Role",
on_delete=models.CASCADE,
related_name="permissions",
verbose_name="所属角色",
help_text="稀疏存储:角色删除时级联清理权限值",
)
permission_def = models.ForeignKey(
"fonrey_permission.PermissionDef",
on_delete=models.PROTECT,
related_name="role_assignments",
verbose_name="权限定义",
help_text="RESTRICT 防止删除仍被引用的权限项",
)
value = models.JSONField(
verbose_name="权限值",
help_text='统一格式 {"v": <value>}',
)
value = models.JSONField()
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="role_permissions_updated",
verbose_name="最后修改人",
)
class Meta:
db_table = "role_permissions"
verbose_name = "角色权限"
verbose_name_plural = "角色权限"
constraints = [
models.UniqueConstraint(
fields=["role", "permission_def"],

View File

@@ -14,26 +14,49 @@ class StaffRole(UUIDPrimaryKeyModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="staff_roles",
verbose_name="所属员工",
help_text="员工删除时级联删除角色关联",
)
role = models.ForeignKey(
"fonrey_permission.Role",
on_delete=models.PROTECT,
related_name="staff_links",
verbose_name="角色",
help_text="角色被员工引用时禁止删除",
)
is_primary = models.BooleanField(
default=False,
verbose_name="是否主角色",
help_text="每个员工有且仅有一个主角色",
)
assigned_at = models.DateTimeField(
auto_now_add=True,
verbose_name="分配时间",
)
is_primary = models.BooleanField(default=False)
assigned_at = models.DateTimeField(auto_now_add=True)
assigned_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="staff_role_assignments_made",
verbose_name="分配操作人",
)
valid_from = models.DateField(
null=True,
blank=True,
verbose_name="生效日",
help_text='预留未来「定时生效」功能',
)
valid_until = models.DateField(
null=True,
blank=True,
verbose_name="失效日",
)
valid_from = models.DateField(null=True, blank=True)
valid_until = models.DateField(null=True, blank=True)
class Meta:
db_table = "staff_roles"
verbose_name = "员工角色"
verbose_name_plural = "员工角色"
constraints = [
models.UniqueConstraint(
fields=["staff", "role"],
@@ -59,30 +82,49 @@ class StaffPermissionOverride(UUIDPrimaryKeyModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="permission_overrides",
verbose_name="所属员工",
help_text="员工删除时级联删除覆盖记录",
)
permission_def = models.ForeignKey(
"fonrey_permission.PermissionDef",
on_delete=models.PROTECT,
related_name="staff_overrides",
verbose_name="被覆盖权限项",
)
value = models.JSONField(
verbose_name="个人权限值",
help_text='统一格式 {"v": <value>}',
)
value = models.JSONField()
override_mode = models.CharField(
max_length=10,
choices=PermissionOverrideMode.choices,
default=PermissionOverrideMode.REPLACE,
verbose_name="覆盖模式",
help_text="REPLACE=替换合并值 / RESTRICT=限制上限 / GRANT=仅扩展",
)
reason = models.TextField(
blank=True,
default="",
verbose_name="备注",
help_text="管理员备注,建议强制填写以便审计",
)
reason = models.TextField(blank=True, default="")
modified_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="staff_overrides_modified",
verbose_name="修改人",
)
modified_at = models.DateTimeField(
auto_now=True,
verbose_name="最近修改时间",
)
modified_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "staff_permission_overrides"
verbose_name = "个人权限覆盖"
verbose_name_plural = "个人权限覆盖"
constraints = [
models.UniqueConstraint(
fields=["staff", "permission_def"],
@@ -99,10 +141,14 @@ class StaffDataScope(UUIDPrimaryKeyModel):
"org.Staff",
on_delete=models.CASCADE,
related_name="data_scopes",
verbose_name="所属员工",
help_text="员工删除时级联删除范围记录",
)
scope_type = models.CharField(
max_length=20,
choices=PermissionDataScopeType.choices,
verbose_name="范围类型",
help_text="self=本人 / group=本组 / store=本门店 / area=本区域 / region=本大区 / company=全公司 / custom_unit=指定节点",
)
org_unit = models.ForeignKey(
"org.OrgUnit",
@@ -110,22 +156,45 @@ class StaffDataScope(UUIDPrimaryKeyModel):
blank=True,
on_delete=models.PROTECT,
related_name="data_scope_grants",
verbose_name="组织节点",
help_text="scope_type=custom_unit 时必填,其他类型为 NULL",
)
is_readable = models.BooleanField(
default=True,
verbose_name="可读",
)
is_writable = models.BooleanField(
default=False,
verbose_name="可写",
help_text="默认只读",
)
is_readable = models.BooleanField(default=True)
is_writable = models.BooleanField(default=False)
granted_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="data_scopes_granted",
verbose_name="授权操作人",
)
granted_at = models.DateTimeField(
auto_now_add=True,
verbose_name="授权时间",
)
expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="临时授权失效时间",
)
reason = models.TextField(
blank=True,
default="",
verbose_name="授予原因",
)
granted_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
reason = models.TextField(blank=True, default="")
class Meta:
db_table = "staff_data_scopes"
verbose_name = "员工数据范围"
verbose_name_plural = "员工数据范围"
indexes = [
models.Index(fields=["staff"], name="idx_data_scopes_staff"),
models.Index(fields=["org_unit"], name="idx_data_scopes_org"),
@@ -141,14 +210,20 @@ class PermissionChangeLog(UUIDPrimaryKeyModel):
target_type = models.CharField(
max_length=30,
choices=PermissionChangeTargetType.choices,
verbose_name="变更对象类型",
help_text="role / role_permission / staff_role / staff_override / staff_scope",
)
target_id = models.UUIDField(
verbose_name="变更对象 ID",
)
target_id = models.UUIDField()
staff = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="permission_change_logs_affecting",
verbose_name="被影响员工",
help_text="target 是 staff_role/staff_override/staff_scope 时必填",
)
role = models.ForeignKey(
"fonrey_permission.Role",
@@ -156,23 +231,63 @@ class PermissionChangeLog(UUIDPrimaryKeyModel):
blank=True,
on_delete=models.SET_NULL,
related_name="change_logs",
verbose_name="被影响角色",
)
permission_code = models.CharField(
max_length=150,
blank=True,
default="",
verbose_name="权限编码",
help_text="用 code 而非 FK避免 PermissionDef 删除后日志丢失",
)
action = models.CharField(
max_length=20,
choices=PermissionChangeAction.choices,
verbose_name="操作动作",
help_text="create / update / delete / assign / revoke",
)
old_value = models.JSONField(
null=True,
blank=True,
verbose_name="变更前快照",
)
new_value = models.JSONField(
null=True,
blank=True,
verbose_name="变更后快照",
)
permission_code = models.CharField(max_length=150, blank=True, default="")
action = models.CharField(max_length=20, choices=PermissionChangeAction.choices)
old_value = models.JSONField(null=True, blank=True)
new_value = models.JSONField(null=True, blank=True)
operator = models.ForeignKey(
"org.Staff",
on_delete=models.PROTECT,
related_name="permission_changes_operated",
verbose_name="操作人",
)
operator_ip = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name="操作来源 IP",
)
user_agent = models.TextField(
blank=True,
default="",
verbose_name="操作终端 UA",
)
reason = models.TextField(
blank=True,
default="",
verbose_name="操作原因",
help_text="批量设置角色等场景强制填写",
)
operated_at = models.DateTimeField(
auto_now_add=True,
verbose_name="操作时间",
help_text="append-only 流水,分区键",
)
operator_ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True, default="")
reason = models.TextField(blank=True, default="")
operated_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "permission_change_logs"
verbose_name = "权限变更流水"
verbose_name_plural = "权限变更流水"
ordering = ["-operated_at"]
indexes = [
models.Index(

0
apps/property/admin.py Normal file
View File

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -0,0 +1,105 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fonrey_property', '0002_partitions_and_triggers'),
]
operations = [
migrations.AlterModelOptions(
name='commission',
options={'verbose_name': '委托管理', 'verbose_name_plural': '委托管理'},
),
migrations.AlterModelOptions(
name='commissionattachment',
options={'verbose_name': '委托附件', 'verbose_name_plural': '委托附件'},
),
migrations.AlterModelOptions(
name='fieldsurvey',
options={'verbose_name': '实勘记录', 'verbose_name_plural': '实勘记录'},
),
migrations.AlterModelOptions(
name='followlog',
options={'managed': False, 'verbose_name': '房源跟进日志', 'verbose_name_plural': '房源跟进日志'},
),
migrations.AlterModelOptions(
name='followlogattachment',
options={'verbose_name': '跟进附件', 'verbose_name_plural': '跟进附件'},
),
migrations.AlterModelOptions(
name='followlogrecording',
options={'verbose_name': '跟进录音', 'verbose_name_plural': '跟进录音'},
),
migrations.AlterModelOptions(
name='keyattachment',
options={'verbose_name': '钥匙附件', 'verbose_name_plural': '钥匙附件'},
),
migrations.AlterModelOptions(
name='listinghistory',
options={'verbose_name': '挂牌历史', 'verbose_name_plural': '挂牌历史'},
),
migrations.AlterModelOptions(
name='numberholderapproval',
options={'verbose_name': '号码方审批', 'verbose_name_plural': '号码方审批'},
),
migrations.AlterModelOptions(
name='pricechange',
options={'verbose_name': '调价记录', 'verbose_name_plural': '调价记录'},
),
migrations.AlterModelOptions(
name='property',
options={'verbose_name': '房源', 'verbose_name_plural': '房源'},
),
migrations.AlterModelOptions(
name='propertyattachment',
options={'verbose_name': '房源附件', 'verbose_name_plural': '房源附件'},
),
migrations.AlterModelOptions(
name='propertycertificate',
options={'verbose_name': '房源产证', 'verbose_name_plural': '房源产证'},
),
migrations.AlterModelOptions(
name='propertycompleteness',
options={'verbose_name': '房源完整度', 'verbose_name_plural': '房源完整度'},
),
migrations.AlterModelOptions(
name='propertycontact',
options={'verbose_name': '房源联系人', 'verbose_name_plural': '房源联系人'},
),
migrations.AlterModelOptions(
name='propertyfavorite',
options={'verbose_name': '房源收藏', 'verbose_name_plural': '房源收藏'},
),
migrations.AlterModelOptions(
name='propertykey',
options={'verbose_name': '房源钥匙', 'verbose_name_plural': '房源钥匙'},
),
migrations.AlterModelOptions(
name='propertymarketing',
options={'verbose_name': '房源营销信息', 'verbose_name_plural': '房源营销信息'},
),
migrations.AlterModelOptions(
name='propertyphoto',
options={'managed': False, 'verbose_name': '房源图片', 'verbose_name_plural': '房源图片'},
),
migrations.AlterModelOptions(
name='propertyprotection',
options={'verbose_name': '房源保护期', 'verbose_name_plural': '房源保护期'},
),
migrations.AlterModelOptions(
name='propertytag',
options={'verbose_name': '房源标签', 'verbose_name_plural': '房源标签'},
),
migrations.AlterModelOptions(
name='propertytagrelation',
options={'verbose_name': '房源标签关联', 'verbose_name_plural': '房源标签关联'},
),
migrations.AlterModelOptions(
name='surveyphoto',
options={'verbose_name': '实勘照片', 'verbose_name_plural': '实勘照片'},
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -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",
]

View File

@@ -0,0 +1,862 @@
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,
verbose_name="房源类型",
help_text="residential=住宅/villa=别墅/commercial_residential=商住/shop=商铺/office=写字楼/other=其他(详见 ENUMS",
)
status = models.CharField(
max_length=20,
choices=PropertyStatus.choices,
default=PropertyStatus.FOR_SALE,
verbose_name="交易状态",
help_text="for_sale=出售/for_rent=出租/for_sale_rent=租售/suspended=暂缓/sold_elsewhere=他售/rented_elsewhere=他租/sold=成交/unlisted=未挂牌(详见 ENUMS",
)
attribute = models.CharField(
max_length=10,
choices=PropertyAttribute.choices,
default=PropertyAttribute.PUBLIC,
verbose_name="流通属性",
help_text="public=公盘/private=私盘/special=特盘/sealed=封盘;控制可见范围",
)
private_reason = models.TextField(
blank=True,
default="",
verbose_name="私盘/封盘原因",
help_text="attribute 为 private/sealed 时必填,最多 200 字",
)
complex = models.ForeignKey(
"fonrey_complex.Complex",
on_delete=models.RESTRICT,
related_name="properties",
verbose_name="所属楼盘",
help_text="房源必须挂在楼盘下,禁止级联删除",
)
building = models.ForeignKey(
"fonrey_complex.Building",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="properties",
verbose_name="所属楼栋",
help_text="楼栋被删除时置 NULL",
)
block_no = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="栋/幢/弄号",
help_text="'3栋''A幢'",
)
unit_no = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="单元号",
help_text="'1单元''055'",
)
room_no = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="房号/门牌号",
help_text="'0301''1502'",
)
floor = models.SmallIntegerField(
verbose_name="所在楼层",
help_text="正整数;不超过 total_floorsCheckConstraint 校验)",
)
total_floors = models.SmallIntegerField(
verbose_name="楼栋总层数",
help_text="正整数",
)
bedroom_count = models.SmallIntegerField(default=0, verbose_name="卧室数(室)")
living_room_count = models.SmallIntegerField(default=0, verbose_name="客厅/餐厅数(厅)")
bathroom_count = models.SmallIntegerField(default=0, verbose_name="卫生间数(卫)")
kitchen_count = models.SmallIntegerField(default=0, verbose_name="厨房数(厨)")
balcony_count = models.SmallIntegerField(default=0, verbose_name="阳台数", help_text="0=无阳台")
area = models.DecimalField(
max_digits=8,
decimal_places=2,
verbose_name="建筑面积",
help_text="含公摊;录入必填",
)
inner_area = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
verbose_name="套内面积",
help_text="不含公摊;选填,编辑页专属字段",
)
sale_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="挂牌售价(万元)",
help_text="出售类房源必填,出租类可为 NULL",
)
sale_bottom_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="售底价(万元)",
help_text="业主心理底价,仅内部可见,不对外展示",
)
sale_record_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="备案/核验价(万元)",
help_text="填写后同步至营销库",
)
rent_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="挂牌租价(元/月)",
help_text="出租类房源使用",
)
orientation = models.CharField(
max_length=15,
blank=True,
default="",
choices=PropertyOrientation.choices,
verbose_name="朝向",
help_text="east=东/south=南/west=西/north=北/southeast=东南/northeast=东北/east_west=东西/south_north=南北/northwest=西北/southwest=西南",
)
decoration = models.CharField(
max_length=10,
blank=True,
default="",
choices=PropertyDecoration.choices,
verbose_name="装修情况",
help_text="rough=毛坯/plain=清水/simple=简装/medium=中装/fine=精装/luxury=豪装",
)
has_elevator = models.BooleanField(
null=True,
blank=True,
verbose_name="是否有电梯",
help_text="true=有/false=无/NULL=未确认",
)
built_year = models.SmallIntegerField(
null=True,
blank=True,
verbose_name="建成年份",
help_text="如 2018可空老房源无记录影响营销发房",
)
usage_type = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="房屋用途大类",
help_text="如:住宅/商住/商业;对应更改用途浮窗第一级下拉",
)
usage_subtype = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="房屋用途细分小类",
help_text="如:普通住宅/花园洋房;对应更改用途浮窗第二级下拉",
)
shop_frontage = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
verbose_name="开间(米)",
help_text="商铺专属,住宅类为 NULL",
)
shop_depth = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
verbose_name="进深(米)",
help_text="商铺专属",
)
shop_height = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
verbose_name="层高(米)",
help_text="商铺专属",
)
shop_location = models.CharField(
max_length=20,
blank=True,
default="",
choices=PropertyShopLocation.choices,
verbose_name="商铺位置类型",
help_text="street=沿街/mall=商场内/residential=住宅底商/ground_floor=楼栋底层/complex=综合体(商铺专属)",
)
house_status = models.CharField(
max_length=20,
blank=True,
default="",
choices=PropertyHouseStatus.choices,
verbose_name="房屋现状",
help_text="owner_occupied=业主自住/vacant=空置/tenant_occupied=租客租住/unknown=未知;影响带看安排",
)
viewing_time = models.CharField(
max_length=20,
blank=True,
default="",
choices=PropertyViewingTime.choices,
verbose_name="看房时间安排",
help_text="anytime=随时可看/by_appointment=提前预约/inconvenient=不方便看",
)
grade = models.CharField(
max_length=2,
blank=True,
default="",
choices=PropertyGrade.choices,
verbose_name="房源等级",
help_text="A=急迫/B=较强/C=一般/D=较弱(业主出售意向)",
)
ownership_years = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="房本年限",
help_text="不满2年/满2年/满5年 等(影响交易税费)",
)
ownership_years_detail = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="房本年限辅助说明",
help_text="满五/不满五(与 ownership_years 组合使用)",
)
ownership_nature = models.CharField(
max_length=20,
blank=True,
default="",
choices=PropertyOwnershipNature.choices,
verbose_name="产权性质",
help_text="commercial=商品房/reform_housing=房改房/collective=集资房/economic=经济活用房",
)
is_only_house = models.BooleanField(
null=True,
blank=True,
verbose_name="是否唯一住房",
help_text="true=唯一/false=非唯一/NULL=未确认;影响交易税费计算",
)
payment_method = models.CharField(
max_length=15,
blank=True,
default="",
choices=PropertyPaymentMethod.choices,
verbose_name="购房付款方式",
help_text="full=一次付清/mortgage=按揭付款/installment=分批次付款/advance=垫资解按",
)
tax_included = models.CharField(
max_length=15,
blank=True,
default="",
choices=PropertyTaxIncluded.choices,
verbose_name="包税费方式",
help_text="each_party=各付/net=到手/inclusive=包税",
)
has_mortgage = models.BooleanField(
null=True,
blank=True,
verbose_name="是否有抵押",
help_text="true=有/false=无/NULL=未确认",
)
has_loan = models.BooleanField(
null=True,
blank=True,
verbose_name="是否有贷款(未还清)",
help_text="true=有/false=无/NULL=未确认",
)
has_seal = models.BooleanField(
null=True,
blank=True,
verbose_name="是否被查封",
help_text="true=有/false=无/NULL=未确认",
)
has_restriction = models.BooleanField(
null=True,
blank=True,
verbose_name="是否有其他限制",
help_text="true=有/false=无/NULL=未确认",
)
original_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="原购价(万元)",
help_text="业主当年购入价,用于计算增值",
)
sale_reason = models.TextField(
blank=True,
default="",
verbose_name="售房原因",
help_text="业主出售理由,最多 200 字;如'置换'",
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="房源备注",
help_text="经纪人内部备注,最多 500 字,不对外展示",
)
first_recorder = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="first_recorded_properties",
verbose_name="首录方",
help_text="最初录入该房源的经纪人;人员离职后置 NULL",
)
number_holder = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="held_properties",
verbose_name="号码方",
help_text="持有业主联系号码的经纪人;变更需走审批流",
)
seller_agent = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="selling_properties",
verbose_name="出售方",
help_text="负责出售跟进的经纪人",
)
buyer_agent = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="buying_properties",
verbose_name="实买方",
help_text="促成成交的买方经纪人",
)
source = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="房源来源渠道",
help_text="枚举值由 lookup_items 维护,如:门店拓客/转介绍/网络等",
)
completeness_score = models.SmallIntegerField(
default=0,
verbose_name="维护完成度评分",
help_text="0-100由 Celery 异步计算,非实时;前端列表页展示徽章",
)
listed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="最近一次挂牌时间",
help_text="每次重新挂牌时更新",
)
last_followed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="最后跟进时间",
help_text="冗余字段,由触发器自动维护,加速超时未跟进排序",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_properties",
verbose_name="创建人",
)
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_properties",
verbose_name="最后修改人",
)
search_vector = SearchVectorField(
null=True,
blank=True,
verbose_name="全文检索向量",
help_text="由触发器自动维护,覆盖栋号/单元/房号/备注",
)
version = models.IntegerField(
default=1,
verbose_name="乐观锁版本号",
help_text="每次 UPDATE 必须 +1应用层检测 0 行受影响时抛 ConflictError",
)
class Meta:
db_table = "properties"
verbose_name = "房源"
verbose_name_plural = "房源"
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",
verbose_name="所属房源",
help_text="房源删除时联级删除",
)
name = models.CharField(
max_length=50,
verbose_name="联系人姓名",
help_text="'张先生';业主或其代理人的真实姓名",
)
gender = models.CharField(
max_length=10,
choices=PropertyContactGender.choices,
default=PropertyContactGender.MALE,
verbose_name="性别",
help_text="male=先生/female=女士",
)
identity = models.CharField(
max_length=20,
choices=PropertyContactIdentity.choices,
default=PropertyContactIdentity.CONTACT,
verbose_name="联系人身份",
help_text="owner=业主/contact=联系人/subletter=二房东/tenant=租客/agent=代理人/corporate=企业法人",
)
phone_enc = models.BinaryField(
verbose_name="手机号1密文",
help_text="AES-256-GCM 加密,不可直接查询",
)
phone_hash = models.CharField(
max_length=64,
verbose_name="手机号1哈希",
help_text="SHA-256用于重复房源检测和精确查询",
)
phone2_enc = models.BinaryField(
null=True,
blank=True,
verbose_name="手机号2密文",
help_text="AES-256-GCM 加密;选填",
)
phone2_hash = models.CharField(
max_length=64,
blank=True,
default="",
verbose_name="手机号2哈希",
help_text="SHA-256phone2_enc 存在时必填",
)
wechat = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="微信号",
help_text="选填;无数据时前端展示'-'",
)
qq = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="QQ号",
help_text="选填;无数据时前端展示'-'",
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="备注",
help_text="最多 200 字;补充说明联系人情况",
)
is_number_holder = models.BooleanField(
default=False,
verbose_name="是否为号码方",
help_text="true=是号码方(审批通过)/false=否;号码方变更须走审批流",
)
number_holder_approved_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="号码方审批通过时间",
help_text="NULL=尚未成为号码方",
)
sort_order = models.IntegerField(
default=0,
verbose_name="排序权重",
help_text="数值越小越靠前;控制联系人在面板中的显示顺序",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_property_contacts",
verbose_name="创建人",
)
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_property_contacts",
verbose_name="最后修改人",
)
class Meta:
db_table = "property_contacts"
verbose_name = "房源联系人"
verbose_name_plural = "房源联系人"
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",
verbose_name="所属房源",
help_text="1:1 关联 properties 表",
)
marketing_title = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="营销标题",
help_text="0-30 字;前端发房时展示给买家的吸睛标题",
)
core_selling_points = models.TextField(
blank=True,
default="",
verbose_name="核心卖点",
help_text="最多 200 字;展示给买家的重点卖点说明",
)
owner_attitude = models.TextField(
blank=True,
default="",
verbose_name="业主心态",
help_text="最多 200 字;仅内部可见,描述业主议价空间和心理状态",
)
layout_description = models.TextField(
blank=True,
default="",
verbose_name="户型介绍",
help_text="最多 200 字;房源户型特点描述,面向买家展示",
)
complex_description = models.TextField(
blank=True,
default="",
verbose_name="小区介绍",
help_text="最多 200 字;楼盘/小区周边配套描述",
)
ai_generated_points = models.BooleanField(
default=False,
verbose_name="核心卖点AI生成",
help_text="true=AI辅助生成经纪人确认后使用",
)
ai_generated_attitude = models.BooleanField(
default=False,
verbose_name="业主心态AI生成",
help_text="true=AI辅助生成",
)
updated_at = models.DateTimeField(auto_now=True, verbose_name="最后更新时间")
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="最后修改人",
)
class Meta:
db_table = "property_marketing"
verbose_name = "房源营销信息"
verbose_name_plural = "房源营销信息"
class PropertyCertificate(UUIDPrimaryKeyModel):
property = models.OneToOneField(
Property,
on_delete=models.CASCADE,
related_name="certificate",
verbose_name="所属房源",
help_text="1:1 关联 properties 表",
)
owner_name = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="产权人姓名",
help_text="产权证书上登记的所有权人",
)
owner_id_number = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="产权人证件号码",
help_text="身份证号/统一社会信用代码等",
)
owner_cert_type = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="产权人证件类型",
help_text="如:身份证/护照/营业执照",
)
property_location = models.CharField(
max_length=500,
blank=True,
default="",
verbose_name="房屋坐落",
help_text="产权证书上的完整地址,最多 500 字",
)
cert_status = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="产证状态",
help_text="如:已过户/抵押中/查封/正常",
)
cert_no = models.CharField(
max_length=100,
blank=True,
default="",
verbose_name="产权证号",
help_text="不动产权证书编号",
)
first_registered_at = models.DateField(
null=True,
blank=True,
verbose_name="首次登记时间",
help_text="产权证上的初始登记日期",
)
ownership_nature = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="权属性质",
help_text="如:商品房/经济适用房/回迁房",
)
land_nature = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="土地性质",
help_text="如:国有/集体/划拨/出让",
)
updated_at = models.DateTimeField(auto_now=True, verbose_name="最后更新时间")
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="最后修改人",
)
class Meta:
db_table = "property_certificates"
verbose_name = "房源产证"
verbose_name_plural = "房源产证"
class PropertyCompleteness(UUIDPrimaryKeyModel):
property = models.OneToOneField(
Property,
on_delete=models.CASCADE,
related_name="completeness",
verbose_name="所属房源",
help_text="1:1 关联 properties 表",
)
score_core_info = models.SmallIntegerField(
default=0,
verbose_name="重点信息得分",
help_text="满分 8包含房源核心字段完整度",
)
score_attachment = models.SmallIntegerField(
default=0,
verbose_name="附件得分",
help_text="满分 8身份证/产权证/委托书等材料上传情况",
)
score_survey = models.SmallIntegerField(
default=0,
verbose_name="实勘得分",
help_text="满分 16实勘照片和报告完整度",
)
score_vr = models.SmallIntegerField(
default=0,
verbose_name="VR得分",
help_text="满分 8VR/全景照片上传情况",
)
score_key = models.SmallIntegerField(
default=0,
verbose_name="钥匙得分",
help_text="满分 10钥匙托管情况",
)
score_commission = models.SmallIntegerField(
default=0,
verbose_name="委托得分",
help_text="满分 10独家/普通委托书情况",
)
score_verification = models.SmallIntegerField(
default=0,
verbose_name="验证得分",
help_text="满分 7房源信息核实情况",
)
score_follow_up = models.SmallIntegerField(
default=0,
verbose_name="跟进得分",
help_text="满分 8近期跟进记录情况",
)
score_viewing = models.SmallIntegerField(
default=0,
verbose_name="带看得分",
help_text="满分 8带看记录完整度",
)
score_other = models.SmallIntegerField(
default=0,
verbose_name="其他得分",
help_text="满分 7其他加分项",
)
total_score = models.SmallIntegerField(
default=0,
verbose_name="维护完成度总分",
help_text="0-100供列表排序用与 properties.completeness_score 冗余",
)
calculated_at = models.DateTimeField(
auto_now=True,
verbose_name="最近计算时间",
help_text="最近一次 Celery 任务异步计算完成时间",
)
class Meta:
db_table = "property_completeness"
verbose_name = "房源完整度"
verbose_name_plural = "房源完整度"
class PropertyProtection(UUIDPrimaryKeyModel):
property = models.OneToOneField(
Property,
on_delete=models.CASCADE,
related_name="protection",
verbose_name="所属房源",
help_text="1:1 关联 properties 表",
)
is_protected = models.BooleanField(
default=False,
verbose_name="是否处于保护状态",
help_text="true=受保护(防止被他人抢单/公盘化)/false=未保护",
)
reason = models.TextField(
blank=True,
default="",
verbose_name="保护原因",
help_text="说明为何启用保护",
)
start_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="保护开始时间",
help_text="NULL=尚未生效",
)
end_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="保护到期时间",
help_text="NULL=长期保护",
)
set_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="设置人",
help_text="人员离职后置 NULL",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
class Meta:
db_table = "property_protections"
verbose_name = "房源保护期"
verbose_name_plural = "房源保护期"

View File

@@ -0,0 +1,278 @@
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, verbose_name="主键")
created_at = models.DateTimeField(
verbose_name="创建时间",
help_text="分区键,必须在最前声明;系统自动",
)
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.CASCADE,
related_name="follow_logs",
verbose_name="所属房源",
)
log_type = models.CharField(
max_length=30,
choices=PropertyFollowLogType.choices,
verbose_name="跟进日志类型",
help_text="written=经纪人主动写入/modified=字段变更自动生成/sensitive_op=敏感操作跟进/sensitive_view=敏感信息查看(不可删)/other=其他/system=系统日志",
)
purpose = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="跟进目的",
help_text="枚举值由 lookup_items 维护,如:电话/业主跟进/议价/带看;仅 written 类型使用",
)
content = models.TextField(
blank=True,
default="",
verbose_name="跟进内容",
help_text="最少 6 字,最多 500 字;仅 written 类型必填",
)
ai_tag = models.CharField(
max_length=20,
blank=True,
default="",
choices=PropertyFollowAiTag.choices,
verbose_name="AI 辅助标签",
help_text="ai_for_sale=AI判断业主在售/ai_not_for_sale=AI判断业主不售由系统智能分析后打标",
)
change_detail = models.JSONField(
null=True,
blank=True,
verbose_name="字段变更明细",
help_text='格式:{"field": "sale_price", "old": 850, "new": 800, "label": "售价"}modified 类型使用',
)
log_tag = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="前端展示标签",
help_text="如:查看号码/图片下载/改状态/改价格/改等级/修改相关方;对应跟进时间线显示的方括号标签",
)
is_public = models.BooleanField(
default=True,
verbose_name="是否公开",
help_text="true=全员可见/false=仅本人及上级可见",
)
operator = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="操作人",
help_text="人员离职后置 NULL但 snapshot 保留",
)
operator_snapshot = models.JSONField(
null=True,
blank=True,
verbose_name="操作人快照",
help_text="{name, role, org_unit_name, store_group};防止人员离职后丢失显示信息",
)
is_deletable = models.BooleanField(
default=True,
verbose_name="是否可软删除",
help_text="false=敏感信息查看类型,合规要求不可删除",
)
deleted_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="软删除时间戳",
help_text="仅 is_deletable=TRUE 时可软删NULL=未删除",
)
class Meta:
db_table = "follow_logs"
verbose_name = "房源跟进日志"
verbose_name_plural = "房源跟进日志"
managed = False
unique_together = (("id", "created_at"),)
class FollowLogAttachment(UUIDPrimaryKeyModel):
follow_log_id = models.UUIDField(
verbose_name="所属跟进日志ID",
help_text="跨分区外键,未通过 Django FK 强约束;日志删除时联级删除",
)
file_key = models.TextField(
verbose_name="图片存储路径",
help_text="Cloudflare R2 对象路径",
)
file_name = models.CharField(
max_length=255,
verbose_name="原始文件名",
help_text="用户上传时的文件名",
)
file_size = models.IntegerField(
verbose_name="文件大小",
help_text="bytes最大 20MB = 20971520",
)
file_type = models.CharField(
max_length=10,
blank=True,
default="",
choices=PropertyFollowAttachmentFileType.choices,
verbose_name="文件格式",
help_text="bmp/jpg/png/svg/gifPRD 限定格式)",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序权重",
help_text="控制同一跟进附件的显示顺序",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
class Meta:
db_table = "follow_log_attachments"
verbose_name = "跟进附件"
verbose_name_plural = "跟进附件"
indexes = [models.Index(fields=["follow_log_id"], name="idx_fla_log")]
class FollowLogRecording(UUIDPrimaryKeyModel):
follow_log_id = models.UUIDField(
verbose_name="所属跟进日志ID",
help_text="跨分区外键,未通过 Django FK 强约束;日志删除时联级删除",
)
file_key = models.TextField(
verbose_name="录音文件存储路径",
help_text="Cloudflare R2 对象路径",
)
duration_seconds = models.IntegerField(
null=True,
blank=True,
verbose_name="录音时长",
help_text="秒;可空,上传时若能解析则填写",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
class Meta:
db_table = "follow_log_recordings"
verbose_name = "跟进录音"
verbose_name_plural = "跟进录音"
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",
verbose_name="所属房源",
)
key_type = models.CharField(
max_length=20,
choices=PropertyKeyType.choices,
verbose_name="钥匙类型",
help_text="mechanical=机械钥匙/password=密码(如密码门锁)",
)
holder = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="held_keys",
verbose_name="持有人",
help_text="人员离职后置 NULL",
)
holder_snapshot = models.JSONField(
null=True,
blank=True,
verbose_name="持有人快照",
help_text="{name, store_group};防止人员离职后丢失显示信息",
)
storage_unit = models.ForeignKey(
"org.OrgUnit",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="stored_keys",
verbose_name="保管部门",
help_text="钥匙存放在哪个部门",
)
is_other_agency = models.BooleanField(
default=False,
verbose_name="是否他司钥匙",
help_text="true=是他中介公司的钥匙/false=本司钥匙",
)
other_agency_info = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="他司中介信息",
help_text='最多 30 字is_other_agency=true 时填写,如"链家"',
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="备注",
help_text="最多 200 字;如密码内容等补充说明",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否有效",
help_text="true=在管中/false=已归还或失效",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="最后更新时间")
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_property_keys",
verbose_name="创建人",
)
class Meta:
db_table = "property_keys"
verbose_name = "房源钥匙"
verbose_name_plural = "房源钥匙"
indexes = [models.Index(fields=["property"], name="idx_pk_property")]
class KeyAttachment(UUIDPrimaryKeyModel):
key = models.ForeignKey(
PropertyKey,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name="所属钥匙记录",
help_text="钥匙删除时联级删除",
)
file_key = models.TextField(
verbose_name="附件存储路径",
help_text="Cloudflare R2 对象路径",
)
file_name = models.CharField(max_length=255, verbose_name="原始文件名")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
class Meta:
db_table = "key_attachments"
verbose_name = "钥匙附件"
verbose_name_plural = "钥匙附件"
indexes = [models.Index(fields=["key"], name="idx_ka_key")]

View File

@@ -0,0 +1,445 @@
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",
verbose_name="所属房源",
help_text="禁止级联删除,保留历史",
)
listing_type = models.CharField(
max_length=20,
choices=PropertyListingType.choices,
verbose_name="挂牌类型",
help_text="for_sale=出售挂牌/for_rent=出租挂牌",
)
status = models.CharField(
max_length=10,
choices=PropertyListingHistoryStatus.choices,
default=PropertyListingHistoryStatus.ACTIVE,
verbose_name="挂牌状态",
help_text="active=挂牌中/ended=已结束",
)
sale_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="本次挂牌售价快照",
help_text="万元;出售挂牌时记录",
)
rent_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="本次挂牌租价快照",
help_text="元/月;出租挂牌时记录",
)
sale_unit_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="本次挂牌售价单价",
help_text="元/m²由 sale_price ÷ area 计算后存储",
)
ownership_years = models.CharField(
max_length=30,
blank=True,
default="",
verbose_name="房本年限快照",
help_text='本次挂牌时的房本年限,如"满2年"',
)
is_only_house = models.BooleanField(
null=True,
blank=True,
verbose_name="唯一住房状态快照",
help_text="本次挂牌时的唯一住房状态",
)
tax_included = models.CharField(
max_length=15,
blank=True,
default="",
verbose_name="包税费方式快照",
help_text="each_party=各付/net=到手/inclusive=包税",
)
sale_reason = models.TextField(
blank=True,
default="",
verbose_name="售房原因快照",
help_text="本次挂牌时的售房原因",
)
seller_agent = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="出售经纪人",
help_text="本次挂牌的出售经纪人;人员离职后置 NULL但 snapshot 保留",
)
seller_agent_snapshot = models.JSONField(
null=True,
blank=True,
verbose_name="出售经纪人快照",
help_text="{name, store_group, org_unit_name};防止人员变动后数据丢失",
)
started_at = models.DateTimeField(
auto_now_add=False,
verbose_name="本次挂牌开始时间",
)
ended_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="本次挂牌结束时间",
help_text="NULL=当前仍在挂牌中",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
class Meta:
db_table = "listing_histories"
verbose_name = "挂牌历史"
verbose_name_plural = "挂牌历史"
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",
verbose_name="所属房源",
help_text="禁止级联删除,保留调价历史",
)
old_sale_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价前挂牌售价",
help_text="万元NULL=首次定价",
)
new_sale_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价后挂牌售价",
help_text="万元",
)
old_bottom_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价前售底价",
help_text="万元NULL=未设置",
)
new_bottom_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价后售底价",
help_text="万元NULL=本次不变更底价",
)
old_record_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价前备案/核验价",
help_text="万元NULL=未设置",
)
new_record_price = models.DecimalField(
max_digits=12,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价后备案/核验价",
help_text="万元NULL=本次不变更",
)
old_rent_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价前挂牌租价",
help_text="元/月NULL=非出租类或未设置",
)
new_rent_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="调价后挂牌租价",
help_text="元/月",
)
change_reason = models.TextField(
verbose_name="调价原因",
help_text='必填,最多 200 字;如"业主主动降价"',
)
changed_at = models.DateTimeField(auto_now_add=True, verbose_name="调价操作时间")
changed_by = models.ForeignKey(
"org.Staff",
on_delete=models.RESTRICT,
verbose_name="操作人",
help_text="禁止置 NULL保留审计追溯",
)
class Meta:
db_table = "price_changes"
verbose_name = "调价记录"
verbose_name_plural = "调价记录"
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",
verbose_name="所属房源",
)
commission_type = models.CharField(
max_length=50,
verbose_name="委托类型",
help_text="独家委托/非独家委托;由 lookup_items 维护",
)
period_start = models.DateField(verbose_name="委托开始日期")
period_end = models.DateField(
null=True,
blank=True,
verbose_name="委托结束日期",
help_text="is_open_ended=true 时为 NULL",
)
is_open_ended = models.BooleanField(
default=False,
verbose_name="是否无固定结束日期",
help_text="true=长期委托/false=有截止日期",
)
agent = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="commissions_as_agent",
verbose_name="委托经纪人",
help_text="人员离职后置 NULL",
)
agent_snapshot = models.JSONField(
null=True,
blank=True,
verbose_name="经纪人快照",
help_text="{name, store_group};防止人员变动后数据丢失",
)
signing_method = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="签约方式",
help_text="选择后动态展示委托书模板",
)
owner_type = models.CharField(
max_length=20,
choices=PropertyCommissionOwnerType.choices,
default=PropertyCommissionOwnerType.OWNER,
verbose_name="委托人类型",
help_text="owner=产权人本人/authorized_third=被授权第三方",
)
property_owner_contact = models.ForeignKey(
"fonrey_property.PropertyContact",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="commissions",
verbose_name="关联联系人",
help_text="若委托人已录入联系人则关联,否则填写下方姓名/证件",
)
owner_name = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="委托人姓名",
)
owner_id_type = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="委托人证件类型",
help_text="如:身份证/护照",
)
owner_id_number = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="委托人证件号明文",
help_text="仅供参考;加密版本见 owner_id_number_enc",
)
owner_id_number_enc = models.BinaryField(
null=True,
blank=True,
verbose_name="委托人证件号密文",
help_text="AES-256-GCM 加密",
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="备注",
help_text="最多 200 字",
)
status = models.CharField(
max_length=20,
choices=PropertyCommissionStatus.choices,
default=PropertyCommissionStatus.ACTIVE,
verbose_name="委托状态",
help_text="active=有效/expired=已过期/cancelled=已取消",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_commissions",
verbose_name="创建人",
)
class Meta:
db_table = "commissions"
verbose_name = "委托管理"
verbose_name_plural = "委托管理"
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",
verbose_name="所属委托",
help_text="委托删除时联级删除",
)
category = models.CharField(
max_length=20,
choices=PropertyCommissionAttachmentCategory.choices,
verbose_name="附件分类",
help_text="id_card=身份证/property_cert=产权证书/commission_letter=委托书/other=其他材料",
)
file_key = models.TextField(
verbose_name="附件存储路径",
help_text="Cloudflare R2 对象路径",
)
file_name = models.CharField(max_length=255, verbose_name="原始文件名")
file_size = models.IntegerField(
null=True,
blank=True,
verbose_name="文件大小",
help_text="bytes",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序权重",
help_text="数值越小越靠前",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
class Meta:
db_table = "commission_attachments"
verbose_name = "委托附件"
verbose_name_plural = "委托附件"
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",
verbose_name="所属房源",
)
contact = models.ForeignKey(
"fonrey_property.PropertyContact",
on_delete=models.CASCADE,
related_name="number_holder_approvals",
verbose_name="申请变更的联系方",
help_text="即号码方候选联系人",
)
applicant = models.ForeignKey(
"org.Staff",
on_delete=models.RESTRICT,
related_name="nh_applications",
verbose_name="申请人",
help_text="提交号码方变更申请的经纪人;禁止置 NULL 保留审计",
)
approver = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="nh_approvals",
verbose_name="审批人",
help_text="上级审批人;审批前为 NULL",
)
status = models.CharField(
max_length=20,
choices=PropertyNumberHolderApprovalStatus.choices,
default=PropertyNumberHolderApprovalStatus.PENDING,
verbose_name="审批状态",
help_text="pending=待审批/approved=已通过/rejected=已驳回",
)
remarks = models.TextField(
blank=True,
default="",
verbose_name="审批备注",
help_text="审批人填写的意见或驳回原因",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请提交时间")
decided_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="审批决定时间",
help_text="NULL=尚未审批",
)
class Meta:
db_table = "number_holder_approvals"
verbose_name = "号码方审批"
verbose_name_plural = "号码方审批"
indexes = [
models.Index(fields=["status"], name="idx_nha_status"),
models.Index(fields=["property"], name="idx_nha_property"),
]

View File

@@ -0,0 +1,374 @@
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",
verbose_name="所属房源",
)
status = models.CharField(
max_length=10,
choices=PropertyFieldSurveyStatus.choices,
default=PropertyFieldSurveyStatus.DRAFT,
verbose_name="实勘状态",
help_text="draft=草稿(未提交)/submitted=已提交(已完成)",
)
gps_latitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="GPS 纬度",
help_text="实勘打卡位置;精度 7 位小数",
)
gps_longitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="GPS 经度",
help_text="实勘打卡位置;精度 7 位小数",
)
gps_accuracy = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
verbose_name="GPS 精度",
help_text="米;标注定位误差",
)
description = models.TextField(
blank=True,
default="",
verbose_name="实勘说明",
help_text="最多 200 字;经纪人现场情况描述",
)
submitted_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="提交时间",
help_text="status 变为 submitted 时记录NULL=尚未提交",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="最后更新时间")
created_by = models.ForeignKey(
"org.Staff",
on_delete=models.RESTRICT,
verbose_name="实勘人",
help_text="禁止置 NULL 保留审计",
)
class Meta:
db_table = "field_surveys"
verbose_name = "实勘记录"
verbose_name_plural = "实勘记录"
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",
verbose_name="所属实勘",
help_text="实勘删除时联级删除",
)
category = models.CharField(
max_length=20,
choices=PropertySurveyPhotoCategory.choices,
verbose_name="照片空间分类",
help_text="layout=户型图/living_room=客厅/dining_room=餐厅/bedroom=卧室/bathroom=卫生间/kitchen=厨房/entrance=门厅/balcony=阳台/study=书房/indoor_other=室内其他/outdoor=外景",
)
file_key = models.TextField(
verbose_name="原图存储路径",
help_text="Cloudflare R2 对象路径",
)
thumbnail_key = models.TextField(
blank=True,
default="",
verbose_name="缩略图路径",
help_text="Cloudflare Images 自动生成",
)
file_size = models.IntegerField(
null=True,
blank=True,
verbose_name="文件大小",
help_text="bytes",
)
width = models.IntegerField(
null=True,
blank=True,
verbose_name="图片宽度",
help_text="像素;上传时解析",
)
height = models.IntegerField(
null=True,
blank=True,
verbose_name="图片高度",
help_text="像素;上传时解析",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序权重",
help_text="同一空间分类内,数值越小越靠前",
)
is_vr_screenshot = models.BooleanField(
default=False,
verbose_name="是否为VR截图",
help_text="true=全景/VR截图区别于普通实拍照片",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
class Meta:
db_table = "survey_photos"
verbose_name = "实勘照片"
verbose_name_plural = "实勘照片"
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, verbose_name="主键")
created_at = models.DateTimeField(
verbose_name="上传时间",
help_text="分区键;系统自动",
)
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.CASCADE,
related_name="photos",
verbose_name="所属房源",
)
category = models.CharField(
max_length=20,
choices=PropertyPhotoCategory.choices,
verbose_name="照片分类",
help_text="cover=封面/entrance=门厅/living_room=客厅/dining_room=餐厅/bedroom=卧室/bathroom=卫生间/kitchen=厨房/balcony=阳台/study=书房/indoor_other=室内其他/outdoor=外景/panorama=全景",
)
file_key = models.TextField(
verbose_name="原图存储路径",
help_text="Cloudflare R2/S3 对象路径",
)
thumbnail_key = models.TextField(
blank=True,
default="",
verbose_name="缩略图路径",
help_text="Cloudflare Images 自动生成",
)
file_name = models.CharField(
max_length=255,
blank=True,
default="",
verbose_name="原始文件名",
)
file_size = models.IntegerField(
null=True,
blank=True,
verbose_name="文件大小",
help_text="bytes",
)
width = models.IntegerField(
null=True,
blank=True,
verbose_name="图片宽度",
help_text="像素;上传时解析",
)
height = models.IntegerField(
null=True,
blank=True,
verbose_name="图片高度",
help_text="像素;上传时解析",
)
is_cover = models.BooleanField(
default=False,
verbose_name="是否为封面图",
help_text="true=封面;每套房源只能有一张封面(唯一约束保证)",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序权重",
help_text="同一房源内,数值越小越靠前",
)
updated_at = models.DateTimeField(auto_now=True, verbose_name="最后更新时间")
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="上传人",
)
class Meta:
db_table = "property_photos"
verbose_name = "房源图片"
verbose_name_plural = "房源图片"
managed = False
unique_together = (("id", "created_at"),)
class PropertyAttachment(UUIDPrimaryKeyModel):
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.CASCADE,
related_name="attachments",
verbose_name="所属房源",
)
category = models.CharField(
max_length=20,
choices=PropertyAttachmentCategory.choices,
default=PropertyAttachmentCategory.OTHER,
verbose_name="附件分类",
help_text="id_card=身份证/property_cert=产权证书/commission_letter=委托书/other=其他材料",
)
file_key = models.TextField(
verbose_name="附件存储路径",
help_text="Cloudflare R2 对象路径",
)
file_name = models.CharField(max_length=255, verbose_name="原始文件名")
file_size = models.IntegerField(
verbose_name="文件大小",
help_text="bytes",
)
file_type = models.CharField(
max_length=50,
blank=True,
default="",
verbose_name="MIME 类型",
help_text="如 application/pdf、image/jpeg",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序权重",
help_text="控制同一房源附件的显示顺序",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="上传人",
)
class Meta:
db_table = "property_attachments"
verbose_name = "房源附件"
verbose_name_plural = "房源附件"
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,
verbose_name="标签名称",
help_text="最多 50 字;如:学区/地铁口/满五唯一",
)
color = models.CharField(
max_length=7,
blank=True,
default="",
verbose_name="显示颜色",
help_text="HEX 色值,如 #FF5733前端标签徽章颜色",
)
is_system = models.BooleanField(
default=False,
verbose_name="是否系统预置",
help_text="true=系统内置标签不可删除false=运营自定义标签可删",
)
sort_order = models.IntegerField(
default=0,
verbose_name="排序权重",
help_text="数值越小越靠前",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="false=已停用不再展示",
)
class Meta:
db_table = "property_tags"
verbose_name = "房源标签"
verbose_name_plural = "房源标签"
class PropertyTagRelation(models.Model):
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.CASCADE,
related_name="tag_relations",
verbose_name="所属房源",
)
tag = models.ForeignKey(
PropertyTag,
on_delete=models.CASCADE,
related_name="property_relations",
verbose_name="所属标签",
)
class Meta:
db_table = "property_tag_relations"
verbose_name = "房源标签关联"
verbose_name_plural = "房源标签关联"
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",
verbose_name="收藏人",
help_text="员工注销时删除收藏记录",
)
property = models.ForeignKey(
"fonrey_property.Property",
on_delete=models.CASCADE,
related_name="favorited_by",
verbose_name="收藏的房源",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="收藏时间")
class Meta:
db_table = "property_favorites"
verbose_name = "房源收藏"
verbose_name_plural = "房源收藏"
constraints = [
models.UniqueConstraint(fields=["staff", "property"], name="uq_pfav_staff_property"),
]
indexes = [models.Index(fields=["staff"], name="idx_pfav_staff")]

View File

View File

0
apps/property/tasks.py Normal file
View File

View File

5
apps/property/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path
app_name = "property"
urlpatterns: list = []

0
apps/property/views.py Normal file
View File

0
apps/region/admin.py Normal file
View File

View File

@@ -0,0 +1,33 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('region', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='businessarea',
options={'ordering': ['district_id', 'sort_order', 'name'], 'verbose_name': '商圈', 'verbose_name_plural': '商圈'},
),
migrations.AlterModelOptions(
name='district',
options={'ordering': ['city', 'sort_order', 'name'], 'verbose_name': '城区', 'verbose_name_plural': '城区'},
),
migrations.AlterModelOptions(
name='metroline',
options={'ordering': ['city', 'sort_order', 'name'], 'verbose_name': '地铁线路', 'verbose_name_plural': '地铁线路'},
),
migrations.AlterModelOptions(
name='metrostation',
options={'ordering': ['metro_line_id', 'sort_order'], 'verbose_name': '地铁站点', 'verbose_name_plural': '地铁站点'},
),
migrations.AlterModelOptions(
name='school',
options={'ordering': ['name'], 'verbose_name': '学校', 'verbose_name_plural': '学校'},
),
]

View File

@@ -0,0 +1,154 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('region', '0002_alter_businessarea_options_alter_district_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='businessarea',
name='district',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='business_areas', to='region.district', verbose_name='所属城区'),
),
migrations.AlterField(
model_name='businessarea',
name='is_active',
field=models.BooleanField(default=True, help_text='False=已停用,不在筛选项中展示', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='businessarea',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True, verbose_name='商圈中心纬度'),
),
migrations.AlterField(
model_name='businessarea',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True, verbose_name='商圈中心经度'),
),
migrations.AlterField(
model_name='businessarea',
name='name',
field=models.CharField(max_length=100, verbose_name='商圈名称'),
),
migrations.AlterField(
model_name='businessarea',
name='sort_order',
field=models.IntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='district',
name='city',
field=models.CharField(help_text='支持多城市扩展,如「上海」「北京」', max_length=50, verbose_name='所属城市'),
),
migrations.AlterField(
model_name='district',
name='is_active',
field=models.BooleanField(default=True, help_text='False=已停用,不在筛选项中展示', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='district',
name='name',
field=models.CharField(help_text='如「静安区」', max_length=50, verbose_name='行政区名称'),
),
migrations.AlterField(
model_name='district',
name='short_name',
field=models.CharField(blank=True, default='', help_text='如「静安」', max_length=20, verbose_name='简称'),
),
migrations.AlterField(
model_name='district',
name='sort_order',
field=models.IntegerField(default=0, help_text='列表展示排序', verbose_name='排序'),
),
migrations.AlterField(
model_name='metroline',
name='city',
field=models.CharField(max_length=50, verbose_name='所属城市'),
),
migrations.AlterField(
model_name='metroline',
name='color',
field=models.CharField(blank=True, default='', help_text='HEX 色值,如 #E3002B', max_length=7, verbose_name='线路颜色'),
),
migrations.AlterField(
model_name='metroline',
name='is_active',
field=models.BooleanField(default=True, help_text='False=已停用,不在筛选项中展示', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='metroline',
name='name',
field=models.CharField(help_text='如「1号线」', max_length=50, verbose_name='线路名称'),
),
migrations.AlterField(
model_name='metroline',
name='sort_order',
field=models.IntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='metrostation',
name='is_active',
field=models.BooleanField(default=True, help_text='False=已停用,不在筛选项中展示', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='metrostation',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True, verbose_name='站点纬度'),
),
migrations.AlterField(
model_name='metrostation',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True, verbose_name='站点经度'),
),
migrations.AlterField(
model_name='metrostation',
name='metro_line',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stations', to='region.metroline', verbose_name='所属线路'),
),
migrations.AlterField(
model_name='metrostation',
name='name',
field=models.CharField(max_length=50, verbose_name='站名'),
),
migrations.AlterField(
model_name='metrostation',
name='sort_order',
field=models.IntegerField(default=0, verbose_name='沿线排序'),
),
migrations.AlterField(
model_name='school',
name='district',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='schools', to='region.district', verbose_name='所属城区'),
),
migrations.AlterField(
model_name='school',
name='is_active',
field=models.BooleanField(default=True, help_text='False=已停用,不在筛选项中展示', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='school',
name='level',
field=models.CharField(blank=True, choices=[('normal', '普通'), ('key', '重点'), ('top', '名校')], default='', help_text='normal=普通 / key=重点 / top=名校', max_length=20, verbose_name='学校等级'),
),
migrations.AlterField(
model_name='school',
name='name',
field=models.CharField(max_length=100, verbose_name='学校名称'),
),
migrations.AlterField(
model_name='school',
name='nature',
field=models.CharField(blank=True, choices=[('public', '公立'), ('private', '私立'), ('international', '国际')], default='', help_text='public=公立 / private=私立 / international=国际学校', max_length=20, verbose_name='学校性质'),
),
migrations.AlterField(
model_name='school',
name='type',
field=models.CharField(blank=True, choices=[('primary', '小学'), ('middle', '初中'), ('high', '高中'), ('k9', '九年一贯制'), ('k12', '十二年一贯制')], default='', help_text='primary=小学 / middle=初中 / high=高中 / k9=九年一贯制 / k12=十二年一贯制', max_length=20, verbose_name='学校类型'),
),
]

View File

@@ -5,14 +5,38 @@ from core.models.base import TimeStampedModel
class District(TimeStampedModel):
city = models.CharField(max_length=50)
name = models.CharField(max_length=50)
short_name = models.CharField(max_length=20, blank=True, default="")
sort_order = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
city = models.CharField(
max_length=50,
verbose_name="所属城市",
help_text="支持多城市扩展,如「上海」「北京」",
)
name = models.CharField(
max_length=50,
verbose_name="行政区名称",
help_text="如「静安区」",
)
short_name = models.CharField(
max_length=20,
blank=True,
default="",
verbose_name="简称",
help_text="如「静安」",
)
sort_order = models.IntegerField(
default=0,
verbose_name="排序",
help_text="列表展示排序",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="False=已停用,不在筛选项中展示",
)
class Meta:
db_table = "districts"
verbose_name = "城区"
verbose_name_plural = "城区"
constraints = [
models.UniqueConstraint(
fields=["city", "name"],
@@ -31,15 +55,40 @@ class BusinessArea(TimeStampedModel):
"region.District",
on_delete=models.PROTECT,
related_name="business_areas",
verbose_name="所属城区",
)
name = models.CharField(
max_length=100,
verbose_name="商圈名称",
)
sort_order = models.IntegerField(
default=0,
verbose_name="排序",
)
latitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="商圈中心纬度",
)
longitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="商圈中心经度",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="False=已停用,不在筛选项中展示",
)
name = models.CharField(max_length=100)
sort_order = models.IntegerField(default=0)
latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "business_areas"
verbose_name = "商圈"
verbose_name_plural = "商圈"
constraints = [
models.UniqueConstraint(
fields=["district", "name"],
@@ -60,14 +109,36 @@ class BusinessArea(TimeStampedModel):
class MetroLine(TimeStampedModel):
city = models.CharField(max_length=50)
name = models.CharField(max_length=50)
color = models.CharField(max_length=7, blank=True, default="")
sort_order = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
city = models.CharField(
max_length=50,
verbose_name="所属城市",
)
name = models.CharField(
max_length=50,
verbose_name="线路名称",
help_text="如「1号线」",
)
color = models.CharField(
max_length=7,
blank=True,
default="",
verbose_name="线路颜色",
help_text="HEX 色值,如 #E3002B",
)
sort_order = models.IntegerField(
default=0,
verbose_name="排序",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="False=已停用,不在筛选项中展示",
)
class Meta:
db_table = "metro_lines"
verbose_name = "地铁线路"
verbose_name_plural = "地铁线路"
ordering = ["city", "sort_order", "name"]
def __str__(self) -> str:
@@ -79,15 +150,40 @@ class MetroStation(TimeStampedModel):
"region.MetroLine",
on_delete=models.CASCADE,
related_name="stations",
verbose_name="所属线路",
)
name = models.CharField(
max_length=50,
verbose_name="站名",
)
latitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="站点纬度",
)
longitude = models.DecimalField(
max_digits=10,
decimal_places=7,
null=True,
blank=True,
verbose_name="站点经度",
)
sort_order = models.IntegerField(
default=0,
verbose_name="沿线排序",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="False=已停用,不在筛选项中展示",
)
name = models.CharField(max_length=50)
latitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
longitude = models.DecimalField(max_digits=10, decimal_places=7, null=True, blank=True)
sort_order = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "metro_stations"
verbose_name = "地铁站点"
verbose_name_plural = "地铁站点"
indexes = [
models.Index(
fields=["metro_line"],
@@ -108,30 +204,46 @@ class School(TimeStampedModel):
blank=True,
on_delete=models.SET_NULL,
related_name="schools",
verbose_name="所属城区",
)
name = models.CharField(
max_length=100,
verbose_name="学校名称",
)
name = models.CharField(max_length=100)
type = models.CharField(
max_length=20,
blank=True,
default="",
choices=SchoolType.choices,
verbose_name="学校类型",
help_text="primary=小学 / middle=初中 / high=高中 / k9=九年一贯制 / k12=十二年一贯制",
)
nature = models.CharField(
max_length=20,
blank=True,
default="",
choices=SchoolNature.choices,
verbose_name="学校性质",
help_text="public=公立 / private=私立 / international=国际学校",
)
level = models.CharField(
max_length=20,
blank=True,
default="",
choices=SchoolLevel.choices,
verbose_name="学校等级",
help_text="normal=普通 / key=重点 / top=名校",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="False=已停用,不在筛选项中展示",
)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "schools"
verbose_name = "学校"
verbose_name_plural = "学校"
indexes = [
models.Index(
fields=["district"],

0
apps/release/admin.py Normal file
View File

View File

0
apps/setting/admin.py Normal file
View File

View File

@@ -0,0 +1,114 @@
# Generated by Django 4.2.16 on 2026-04-29 09:33
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('org', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='FieldRequirementRule',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('module', models.CharField(choices=[('property', '房源'), ('client', '客源')], max_length=20)),
('entity_type', models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('shop', '商铺'), ('office', '写字楼'), ('other', '其他')], max_length=50)),
('trade_status', models.CharField(choices=[('sale', '出售'), ('rent', '出租'), ('sale_rent', '租售'), ('*', '全部')], max_length=20)),
('field_key', models.CharField(max_length=50)),
('requirement', models.CharField(choices=[('required', '必填'), ('optional', '选填'), ('hidden', '隐藏')], max_length=10)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'field_requirement_rules',
},
),
migrations.CreateModel(
name='LookupGroup',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('module', models.CharField(max_length=50)),
('key', models.CharField(max_length=100)),
('label_zh', models.CharField(max_length=50)),
('description', models.TextField(blank=True, default='')),
('sort_order', models.SmallIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'lookup_groups',
},
),
migrations.CreateModel(
name='TenantSetting',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('category', models.CharField(max_length=50)),
('key', models.CharField(max_length=100)),
('value', models.JSONField()),
('value_type', models.CharField(choices=[('bool', '布尔'), ('int', '整数'), ('string', '字符串'), ('enum', '枚举')], max_length=20)),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_tenant_settings', to='org.staff')),
],
options={
'db_table': 'tenant_settings',
},
),
migrations.CreateModel(
name='LookupItem',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('value', models.CharField(max_length=100)),
('label_zh', models.CharField(max_length=50)),
('is_system', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('sort_order', models.SmallIntegerField(default=0)),
('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_lookup_items', to='org.staff')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='setting.lookupgroup')),
],
options={
'db_table': 'lookup_items',
},
),
migrations.AddConstraint(
model_name='lookupgroup',
constraint=models.UniqueConstraint(fields=('module', 'key'), name='uq_lookup_groups_module_key'),
),
migrations.AddField(
model_name='fieldrequirementrule',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_field_rules', to='org.staff'),
),
migrations.AddIndex(
model_name='tenantsetting',
index=models.Index(fields=['category'], name='idx_tenant_settings_cat'),
),
migrations.AddConstraint(
model_name='tenantsetting',
constraint=models.UniqueConstraint(fields=('category', 'key'), name='uq_tenant_settings_cat_key'),
),
migrations.AddIndex(
model_name='lookupitem',
index=models.Index(fields=['group', 'is_active', 'sort_order'], name='idx_lookup_items_active'),
),
migrations.AddConstraint(
model_name='lookupitem',
constraint=models.UniqueConstraint(fields=('group', 'value'), name='uq_lookup_items_group_value'),
),
migrations.AddIndex(
model_name='fieldrequirementrule',
index=models.Index(fields=['module', 'entity_type', 'trade_status'], name='idx_field_req_lookup'),
),
migrations.AddConstraint(
model_name='fieldrequirementrule',
constraint=models.UniqueConstraint(fields=('module', 'entity_type', 'trade_status', 'field_key'), name='uq_field_req_quad'),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('setting', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='fieldrequirementrule',
options={'verbose_name': '字段必填规则', 'verbose_name_plural': '字段必填规则'},
),
migrations.AlterModelOptions(
name='lookupgroup',
options={'verbose_name': '查找组', 'verbose_name_plural': '查找组'},
),
migrations.AlterModelOptions(
name='lookupitem',
options={'verbose_name': '查找项', 'verbose_name_plural': '查找项'},
),
migrations.AlterModelOptions(
name='tenantsetting',
options={'verbose_name': '租户设置', 'verbose_name_plural': '租户设置'},
),
]

View File

@@ -0,0 +1,160 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('org', '0003_alter_orgunit_address_city_and_more'),
('setting', '0002_alter_fieldrequirementrule_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='fieldrequirementrule',
name='entity_type',
field=models.CharField(choices=[('residential', '住宅'), ('villa', '别墅'), ('commercial_residential', '商住'), ('shop', '商铺'), ('office', '写字楼'), ('other', '其他')], help_text='与 property.property_type 值域完全一致residential/villa/commercial_residential/shop/office/other', max_length=50, verbose_name='实体类型'),
),
migrations.AlterField(
model_name='fieldrequirementrule',
name='field_key',
field=models.CharField(help_text='如 orientation / decoration / floor / building_area', max_length=50, verbose_name='字段 key'),
),
migrations.AlterField(
model_name='fieldrequirementrule',
name='module',
field=models.CharField(choices=[('property', '房源'), ('client', '客源')], help_text='property / clientMVP 仅 property', max_length=20, verbose_name='模块'),
),
migrations.AlterField(
model_name='fieldrequirementrule',
name='requirement',
field=models.CharField(choices=[('required', '必填'), ('optional', '选填'), ('hidden', '隐藏')], help_text='required=必填 / optional=选填 / hidden=隐藏', max_length=10, verbose_name='规则'),
),
migrations.AlterField(
model_name='fieldrequirementrule',
name='trade_status',
field=models.CharField(choices=[('sale', '出售'), ('rent', '出租'), ('sale_rent', '租售'), ('*', '全部')], help_text='sale=出售 / rent=出租 / sale_rent=租售 / *=全部fallback 通配)', max_length=20, verbose_name='交易状态'),
),
migrations.AlterField(
model_name='fieldrequirementrule',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='fieldrequirementrule',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_field_rules', to='org.staff', verbose_name='最后修改人'),
),
migrations.AlterField(
model_name='lookupgroup',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='lookupgroup',
name='description',
field=models.TextField(blank=True, default='', help_text='前端 tooltip 使用', verbose_name='分组说明'),
),
migrations.AlterField(
model_name='lookupgroup',
name='key',
field=models.CharField(help_text='如 source / follow_purpose', max_length=100, verbose_name='分组 key'),
),
migrations.AlterField(
model_name='lookupgroup',
name='label_zh',
field=models.CharField(help_text='界面显示名称,如「客源来源」', max_length=50, verbose_name='分组中文名'),
),
migrations.AlterField(
model_name='lookupgroup',
name='module',
field=models.CharField(help_text='client / property', max_length=50, verbose_name='所属模块'),
),
migrations.AlterField(
model_name='lookupgroup',
name='sort_order',
field=models.SmallIntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='lookupgroup',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='lookupitem',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='lookupitem',
name='created_by',
field=models.ForeignKey(blank=True, help_text='系统预制时为 NULL', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_lookup_items', to='org.staff', verbose_name='创建人'),
),
migrations.AlterField(
model_name='lookupitem',
name='group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='setting.lookupgroup', verbose_name='所属分组'),
),
migrations.AlterField(
model_name='lookupitem',
name='is_active',
field=models.BooleanField(default=True, help_text='False 后录入下拉不展示,历史记录保留并展示「(已停用)」后缀', verbose_name='是否启用'),
),
migrations.AlterField(
model_name='lookupitem',
name='is_system',
field=models.BooleanField(default=False, help_text='True=系统预制不可物理删除,仅可停用', verbose_name='是否系统预制'),
),
migrations.AlterField(
model_name='lookupitem',
name='label_zh',
field=models.CharField(help_text='如「上门」', max_length=50, verbose_name='显示文本'),
),
migrations.AlterField(
model_name='lookupitem',
name='sort_order',
field=models.SmallIntegerField(default=0, verbose_name='排序'),
),
migrations.AlterField(
model_name='lookupitem',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='lookupitem',
name='value',
field=models.CharField(help_text='英文 snake_case如 door_to_door写入后只读', max_length=100, verbose_name='存储值'),
),
migrations.AlterField(
model_name='tenantsetting',
name='category',
field=models.CharField(help_text='client / property / showroom', max_length=50, verbose_name='配置分类'),
),
migrations.AlterField(
model_name='tenantsetting',
name='key',
field=models.CharField(help_text='如 duplicate_check_scope', max_length=100, verbose_name='配置 key'),
),
migrations.AlterField(
model_name='tenantsetting',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='tenantsetting',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_tenant_settings', to='org.staff', verbose_name='最后修改人'),
),
migrations.AlterField(
model_name='tenantsetting',
name='value',
field=models.JSONField(help_text='JSONB统一格式 {"v": <value>}', verbose_name='配置值'),
),
migrations.AlterField(
model_name='tenantsetting',
name='value_type',
field=models.CharField(choices=[('bool', '布尔'), ('int', '整数'), ('string', '字符串'), ('enum', '枚举')], help_text='bool / int / string / enum用于前端渲染控件', max_length=20, verbose_name='值类型'),
),
]

View File

@@ -0,0 +1,9 @@
from .lookup import LookupGroup, LookupItem
from .setting import FieldRequirementRule, TenantSetting
__all__ = [
"LookupGroup",
"LookupItem",
"TenantSetting",
"FieldRequirementRule",
]

View File

@@ -0,0 +1,115 @@
from django.db import models
from core.models.base import UUIDPrimaryKeyModel
class LookupGroup(UUIDPrimaryKeyModel):
module = models.CharField(
max_length=50,
verbose_name="所属模块",
help_text="client / property",
)
key = models.CharField(
max_length=100,
verbose_name="分组 key",
help_text="如 source / follow_purpose",
)
label_zh = models.CharField(
max_length=50,
verbose_name="分组中文名",
help_text="界面显示名称,如「客源来源」",
)
description = models.TextField(
blank=True,
default="",
verbose_name="分组说明",
help_text="前端 tooltip 使用",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="更新时间",
)
class Meta:
db_table = "lookup_groups"
verbose_name = "查找组"
verbose_name_plural = "查找组"
constraints = [
models.UniqueConstraint(
fields=["module", "key"], name="uq_lookup_groups_module_key"
),
]
class LookupItem(UUIDPrimaryKeyModel):
group = models.ForeignKey(
LookupGroup,
on_delete=models.CASCADE,
related_name="items",
verbose_name="所属分组",
)
value = models.CharField(
max_length=100,
verbose_name="存储值",
help_text="英文 snake_case如 door_to_door写入后只读",
)
label_zh = models.CharField(
max_length=50,
verbose_name="显示文本",
help_text="如「上门」",
)
is_system = models.BooleanField(
default=False,
verbose_name="是否系统预制",
help_text="True=系统预制不可物理删除,仅可停用",
)
is_active = models.BooleanField(
default=True,
verbose_name="是否启用",
help_text="False 后录入下拉不展示,历史记录保留并展示「(已停用)」后缀",
)
sort_order = models.SmallIntegerField(
default=0,
verbose_name="排序",
)
created_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_lookup_items",
verbose_name="创建人",
help_text="系统预制时为 NULL",
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="创建时间",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="更新时间",
)
class Meta:
db_table = "lookup_items"
verbose_name = "查找项"
verbose_name_plural = "查找项"
constraints = [
models.UniqueConstraint(
fields=["group", "value"], name="uq_lookup_items_group_value"
),
]
indexes = [
models.Index(
fields=["group", "is_active", "sort_order"],
name="idx_lookup_items_active",
),
]

View File

@@ -0,0 +1,125 @@
from django.db import models
from core.enums import (
FieldRuleEntityType,
FieldRuleModule,
FieldRuleRequirement,
SettingValueType,
)
from core.models.base import UUIDPrimaryKeyModel
TRADE_STATUS_CHOICES = (
("sale", "出售"),
("rent", "出租"),
("sale_rent", "租售"),
("*", "全部"),
)
class TenantSetting(UUIDPrimaryKeyModel):
category = models.CharField(
max_length=50,
verbose_name="配置分类",
help_text="client / property / showroom",
)
key = models.CharField(
max_length=100,
verbose_name="配置 key",
help_text="如 duplicate_check_scope",
)
value = models.JSONField(
verbose_name="配置值",
help_text='JSONB统一格式 {"v": <value>}',
)
value_type = models.CharField(
max_length=20,
choices=SettingValueType.choices,
verbose_name="值类型",
help_text="bool / int / string / enum用于前端渲染控件",
)
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_tenant_settings",
verbose_name="最后修改人",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="更新时间",
)
class Meta:
db_table = "tenant_settings"
verbose_name = "租户设置"
verbose_name_plural = "租户设置"
constraints = [
models.UniqueConstraint(
fields=["category", "key"], name="uq_tenant_settings_cat_key"
),
]
indexes = [
models.Index(fields=["category"], name="idx_tenant_settings_cat"),
]
class FieldRequirementRule(UUIDPrimaryKeyModel):
module = models.CharField(
max_length=20,
choices=FieldRuleModule.choices,
verbose_name="模块",
help_text="property / clientMVP 仅 property",
)
entity_type = models.CharField(
max_length=50,
choices=FieldRuleEntityType.choices,
verbose_name="实体类型",
help_text="与 property.property_type 值域完全一致residential/villa/commercial_residential/shop/office/other",
)
trade_status = models.CharField(
max_length=20,
choices=TRADE_STATUS_CHOICES,
verbose_name="交易状态",
help_text="sale=出售 / rent=出租 / sale_rent=租售 / *=全部fallback 通配)",
)
field_key = models.CharField(
max_length=50,
verbose_name="字段 key",
help_text="如 orientation / decoration / floor / building_area",
)
requirement = models.CharField(
max_length=10,
choices=FieldRuleRequirement.choices,
verbose_name="规则",
help_text="required=必填 / optional=选填 / hidden=隐藏",
)
updated_by = models.ForeignKey(
"org.Staff",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_field_rules",
verbose_name="最后修改人",
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="更新时间",
)
class Meta:
db_table = "field_requirement_rules"
verbose_name = "字段必填规则"
verbose_name_plural = "字段必填规则"
constraints = [
models.UniqueConstraint(
fields=["module", "entity_type", "trade_status", "field_key"],
name="uq_field_req_quad",
),
]
indexes = [
models.Index(
fields=["module", "entity_type", "trade_status"],
name="idx_field_req_lookup",
),
]

View File

View File

0
apps/setting/tasks.py Normal file
View File

View File

View File

5
apps/setting/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path
app_name = "setting"
urlpatterns: list = []

0
apps/setting/views.py Normal file
View File

0
apps/tenant/admin.py Normal file
View File

View File

@@ -0,0 +1,40 @@
# Generated by Django 4.2.16 on 2026-04-29 11:07
from django.db import migrations, models
import django.db.models.deletion
import django_tenants.postgresql_backend.base
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Tenant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])),
('name', models.CharField(max_length=255)),
('created_on', models.DateField(auto_now_add=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Domain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('domain', models.CharField(db_index=True, max_length=253, unique=True)),
('is_primary', models.BooleanField(db_index=True, default=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tenant.tenant')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.2.16 on 2026-04-30 01:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tenant', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='domain',
options={'verbose_name': '域名', 'verbose_name_plural': '域名'},
),
migrations.AlterModelOptions(
name='tenant',
options={'verbose_name': '租户', 'verbose_name_plural': '租户'},
),
migrations.AlterField(
model_name='tenant',
name='created_on',
field=models.DateField(auto_now_add=True, verbose_name='创建日期'),
),
migrations.AlterField(
model_name='tenant',
name='name',
field=models.CharField(help_text='租户公司名称', max_length=255, verbose_name='公司名称'),
),
]

View File

@@ -3,11 +3,25 @@ from django_tenants.models import DomainMixin, TenantMixin
class Tenant(TenantMixin):
name = models.CharField(max_length=255)
created_on = models.DateField(auto_now_add=True)
name = models.CharField(
max_length=255,
verbose_name="公司名称",
help_text="租户公司名称",
)
created_on = models.DateField(
auto_now_add=True,
verbose_name="创建日期",
)
auto_create_schema = True
class Meta:
verbose_name = "租户"
verbose_name_plural = "租户"
class Domain(DomainMixin):
pass
class Meta:
verbose_name = "域名"
verbose_name_plural = "域名"

View File

View File

@@ -352,6 +352,20 @@ class ComplexPhotoCategory(models.TextChoices):
OTHER = "other", "其他"
class ComplexPropertyUsageType(models.TextChoices):
RESIDENTIAL = "residential", "住宅"
VILLA = "villa", "别墅"
COMMERCIAL_RESIDENTIAL = "commercial_residential", "商住"
COMMERCIAL = "commercial", "商业"
OFFICE = "office", "写字楼"
OTHER = "other", "其他"
class ComplexBuildingStructure(models.TextChoices):
UNIT_ROOM = "unit_room", "单元-房号"
OTHER = "other", "其他"
# ──────────────────────────────────────────────────────────────
# 3.5 property
# ──────────────────────────────────────────────────────────────
@@ -425,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", "女士"

57
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,57 @@
services:
web:
build: .
command: gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 4
env_file: .env
depends_on:
- db
- redis
networks:
- fonrey_net
db:
image: postgres:16-alpine
env_file: .env
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- fonrey_db_data:/var/lib/postgresql/data
networks:
- fonrey_net
redis:
image: redis:7-alpine
volumes:
- fonrey_redis_data:/data
networks:
- fonrey_net
celery:
build: .
command: celery -A config worker -l info --concurrency 4
env_file: .env
depends_on:
- db
- redis
networks:
- fonrey_net
celery-beat:
build: .
command: celery -A config beat -l info
env_file: .env
depends_on:
- db
- redis
networks:
- fonrey_net
volumes:
fonrey_db_data:
fonrey_redis_data:
networks:
fonrey_net:
driver: bridge

78
docker-compose.yml Normal file
View File

@@ -0,0 +1,78 @@
services:
web:
build: .
command: uvicorn config.asgi:application --host 0.0.0.0 --port 8000 --reload
ports:
- "8001:8000"
env_file: .env
volumes:
- .:/app
depends_on:
- db
- redis
networks:
- fonrey_net
db:
image: postgres:16-alpine
ports:
- "5432:5432"
env_file: .env
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- fonrey_db_data:/var/lib/postgresql/data
networks:
- fonrey_net
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- fonrey_redis_data:/data
networks:
- fonrey_net
celery:
build: .
command: celery -A config worker -l info
env_file: .env
volumes:
- .:/app
depends_on:
- db
- redis
networks:
- fonrey_net
celery-beat:
build: .
command: celery -A config beat -l info
env_file: .env
volumes:
- .:/app
depends_on:
- db
- redis
networks:
- fonrey_net
tailwind:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npm run watch"
volumes:
- .:/app
networks:
- fonrey_net
volumes:
fonrey_db_data:
fonrey_redis_data:
networks:
fonrey_net:
driver: bridge

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "fonrey-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tailwindcss -i ./static/css/main.css -o ./static/css/output.css --minify",
"watch": "tailwindcss -i ./static/css/main.css -o ./static/css/output.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}

3
static/css/main.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

32
static/js/main.js Normal file
View File

@@ -0,0 +1,32 @@
document.body.addEventListener("htmx:afterRequest", function (event) {
var trigger = event.detail.xhr.getResponseHeader("HX-Trigger");
if (!trigger) return;
try {
var payload = JSON.parse(trigger);
var toast = payload["fonrey:toast"];
if (!toast) return;
var container = document.getElementById("toast-container");
if (!container) return;
var node = document.createElement("div");
node.className =
"bg-white border border-neutral-200 rounded-lg shadow-xs px-4 py-3 min-w-[280px]";
node.setAttribute("data-toast-type", toast.type || "info");
node.textContent = toast.message || "";
container.appendChild(node);
setTimeout(function () {
node.remove();
}, 4000);
} catch (e) {
}
});
document.body.addEventListener("htmx:configRequest", function (event) {
var meta = document.querySelector('meta[name="csrf-token"]');
if (meta) {
event.detail.headers["X-CSRFToken"] = meta.getAttribute("content");
}
});

0
static/vendor/.gitkeep vendored Normal file
View File

61
tailwind.config.js Normal file
View File

@@ -0,0 +1,61 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./templates/**/*.html",
"./apps/**/templates/**/*.html",
"./static/js/**/*.js",
],
theme: {
extend: {
colors: {
primary: {
50: "#F0FDFA",
100: "#CCFBF1",
200: "#99F6E4",
300: "#5EEAD4",
400: "#2DD4BF",
500: "#14B8A6",
600: "#0F766E",
700: "#115E59",
800: "#134E4A",
},
neutral: {
50: "#F8FAFC",
100: "#F1F5F9",
200: "#E2E8F0",
300: "#CBD5E1",
400: "#94A3B8",
500: "#64748B",
600: "#475569",
700: "#334155",
800: "#1E293B",
900: "#0F172A",
},
success: { 600: "#16A34A" },
warning: { 600: "#D97706" },
danger: { 600: "#DC2626" },
info: { 600: "#2563EB" },
},
fontFamily: {
sans: ["Inter", "PingFang SC", "Microsoft YaHei", "sans-serif"],
},
zIndex: {
60: "60",
70: "70",
},
boxShadow: {
xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
},
keyframes: {
"slide-in-right": {
"0%": { transform: "translateX(100%)", opacity: "0" },
"100%": { transform: "translateX(0)", opacity: "1" },
},
},
animation: {
"slide-in-right": "slide-in-right 0.2s ease-out",
},
},
},
plugins: [],
};

Some files were not shown because too many files have changed in this diff Show More