清空项目,需求有大调整,后续再建骨架
This commit is contained in:
38
.env.example
38
.env.example
@@ -1,38 +0,0 @@
|
||||
# ============================================================
|
||||
# Fonrey .env.example
|
||||
# Copy to .env and fill real values. Never commit .env.
|
||||
# Generate PHONE_ENCRYPTION_KEY:
|
||||
# python -c 'import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())'
|
||||
# Generate SECRET_KEY:
|
||||
# python -c 'import secrets; print(secrets.token_urlsafe(50))'
|
||||
# ============================================================
|
||||
|
||||
# ----- Django -----
|
||||
SECRET_KEY=
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.settings.development
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# ----- Database (PostgreSQL 16) -----
|
||||
DB_NAME=fonrey
|
||||
DB_USER=fonrey
|
||||
DB_PASSWORD=fonrey
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# ----- Redis (Cache + Sessions + Celery) -----
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
CELERY_BROKER_URL=redis://redis:6379/1
|
||||
|
||||
# ----- Cloudflare R2 (S3-compatible) -----
|
||||
R2_ENDPOINT_URL=https://<account_id>.r2.cloudflarestorage.com
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET_NAME=media
|
||||
R2_CUSTOM_DOMAIN=
|
||||
|
||||
# ----- Sentry (production only) -----
|
||||
SENTRY_DSN=
|
||||
|
||||
# ----- PII Encryption (AES-256-GCM, 32 bytes base64) -----
|
||||
PHONE_ENCRYPTION_KEY=
|
||||
56
.gitignore
vendored
56
.gitignore
vendored
@@ -1,56 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Sisyphus session state
|
||||
.sisyphus/
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
media/
|
||||
staticfiles/
|
||||
|
||||
# Tailwind build output
|
||||
static/css/output.css
|
||||
|
||||
# Frontend
|
||||
node_modules/
|
||||
|
||||
# OS / Editor
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing / coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
.tox/
|
||||
|
||||
# Tooling caches
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Generated artifacts
|
||||
openapi.json
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,20 +0,0 @@
|
||||
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
23
Makefile
@@ -1,23 +0,0 @@
|
||||
.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
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.account"
|
||||
label = "account"
|
||||
@@ -1,81 +0,0 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-29 08:42
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserAccount',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('username', models.CharField(max_length=30)),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||
('phone_enc', models.TextField(blank=True, help_text='AES-256-GCM ciphertext of phone (core.encryption.PhoneEncryption).', null=True)),
|
||||
('phone_hash', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('is_tenant_admin', models.BooleanField(default=False)),
|
||||
('status', models.CharField(choices=[('active', '启用'), ('disabled', '停用'), ('locked', '锁定')], default='active', max_length=10)),
|
||||
('is_initial_password', models.BooleanField(default=True)),
|
||||
('locked_until', 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(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_accounts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'user_accounts',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PasswordResetToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reset_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'password_reset_tokens',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PasswordHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password_hash', models.CharField(max_length=128)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_histories', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'password_histories',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LoginAttempt',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('username', models.CharField(max_length=30)),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('user_agent', models.TextField(blank=True, null=True)),
|
||||
('success', models.BooleanField()),
|
||||
('failure_reason', models.CharField(blank=True, choices=[('wrong_password', '用户名或密码错误'), ('wrong_captcha', '验证码错误'), ('account_locked', '账号锁定'), ('account_disabled', '账号停用'), ('tenant_not_found', '租户不存在')], max_length=30, null=True)),
|
||||
('attempted_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'login_attempts',
|
||||
'indexes': [models.Index(fields=['username'], name='idx_login_attempts_username'), models.Index(fields=['ip_address'], name='idx_login_attempts_ip'), models.Index(fields=['-attempted_at'], name='idx_login_attempts_time'), models.Index(fields=['username', 'success', '-attempted_at'], name='idx_login_attempts_fail_check')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,50 +0,0 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-29 08:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('account', '0001_initial'),
|
||||
('org', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='useraccount',
|
||||
name='staff',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='account', to='org.staff'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordresettoken',
|
||||
index=models.Index(fields=['user'], name='idx_pw_reset_tokens_user'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordhistory',
|
||||
index=models.Index(fields=['user', '-created_at'], name='idx_pw_histories_user'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='useraccount',
|
||||
index=models.Index(fields=['status'], name='idx_user_accounts_status'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='useraccount',
|
||||
index=models.Index(fields=['staff'], name='idx_user_accounts_staff'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='useraccount',
|
||||
constraint=models.UniqueConstraint(fields=('username',), name='uq_user_accounts_username'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='useraccount',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('email__isnull', False)), fields=('email',), name='uq_user_accounts_email'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='useraccount',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('phone_hash__isnull', False)), fields=('phone_hash',), name='uq_user_accounts_phone'),
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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': '用户账号'},
|
||||
),
|
||||
]
|
||||
@@ -1,146 +0,0 @@
|
||||
# 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='登录名'),
|
||||
),
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
from apps.account.models.account import (
|
||||
LoginAttempt,
|
||||
PasswordHistory,
|
||||
PasswordResetToken,
|
||||
UserAccount,
|
||||
UserAccountManager,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LoginAttempt",
|
||||
"PasswordHistory",
|
||||
"PasswordResetToken",
|
||||
"UserAccount",
|
||||
"UserAccountManager",
|
||||
]
|
||||
@@ -1,245 +0,0 @@
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from core.enums import LoginFailureReason, UserAccountStatus
|
||||
|
||||
|
||||
class UserAccountManager(BaseUserManager):
|
||||
def create_user(self, username, password=None, **extra_fields):
|
||||
if not username:
|
||||
raise ValueError("username 不能为空")
|
||||
user = self.model(username=username, **extra_fields)
|
||||
if password:
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
|
||||
class UserAccount(AbstractBaseUser):
|
||||
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,
|
||||
verbose_name="手机号(加密)",
|
||||
help_text="AES-256-GCM 加密密文;普通员工必填",
|
||||
)
|
||||
phone_hash = models.CharField(
|
||||
max_length=64,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="手机号哈希",
|
||||
help_text="SHA-256 哈希;用于唯一性校验和查询;不可反推原文",
|
||||
)
|
||||
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 个(应用层约束)",
|
||||
)
|
||||
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="最后更新时间",
|
||||
)
|
||||
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"
|
||||
REQUIRED_FIELDS: list = []
|
||||
|
||||
objects = UserAccountManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "user_accounts"
|
||||
verbose_name = "用户账号"
|
||||
verbose_name_plural = "用户账号"
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["username"], name="uq_user_accounts_username"),
|
||||
models.UniqueConstraint(
|
||||
fields=["email"],
|
||||
name="uq_user_accounts_email",
|
||||
condition=models.Q(email__isnull=False),
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["phone_hash"],
|
||||
name="uq_user_accounts_phone",
|
||||
condition=models.Q(phone_hash__isnull=False),
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["status"], name="idx_user_accounts_status"),
|
||||
models.Index(fields=["staff"], name="idx_user_accounts_staff"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
kind = "admin" if self.is_tenant_admin else "staff"
|
||||
return f"{self.username} ({kind})"
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
if self.status != UserAccountStatus.LOCKED:
|
||||
return False
|
||||
if self.locked_until and timezone.now() >= self.locked_until:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class LoginAttempt(models.Model):
|
||||
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="分区键,按月分区",
|
||||
)
|
||||
|
||||
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"),
|
||||
models.Index(fields=["-attempted_at"], name="idx_login_attempts_time"),
|
||||
models.Index(
|
||||
fields=["username", "success", "-attempted_at"],
|
||||
name="idx_login_attempts_fail_check",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PasswordResetToken(models.Model):
|
||||
user = models.ForeignKey(
|
||||
"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="创建时间",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "password_reset_tokens"
|
||||
verbose_name = "密码重置令牌"
|
||||
verbose_name_plural = "密码重置令牌"
|
||||
indexes = [
|
||||
models.Index(fields=["user"], name="idx_pw_reset_tokens_user"),
|
||||
]
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
return not self.is_used and timezone.now() < self.expires_at
|
||||
|
||||
|
||||
class PasswordHistory(models.Model):
|
||||
user = models.ForeignKey(
|
||||
"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="密码修改时间",
|
||||
)
|
||||
|
||||
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"),
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
app_name = "account"
|
||||
|
||||
urlpatterns: list = []
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ClientConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.client"
|
||||
label = "fonrey_client"
|
||||
@@ -1,351 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,99 +0,0 @@
|
||||
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),
|
||||
]
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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': '带看记录'},
|
||||
),
|
||||
]
|
||||
@@ -1,667 +0,0 @@
|
||||
# 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='带看类型'),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
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",
|
||||
]
|
||||
@@ -1,318 +0,0 @@
|
||||
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"),
|
||||
]
|
||||
@@ -1,291 +0,0 @@
|
||||
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"),
|
||||
]
|
||||
@@ -1,84 +0,0 @@
|
||||
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"),
|
||||
]
|
||||
@@ -1,145 +0,0 @@
|
||||
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")]
|
||||
@@ -1,281 +0,0 @@
|
||||
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"),
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
app_name = "client"
|
||||
|
||||
urlpatterns: list = []
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ComplexConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.complex"
|
||||
label = "fonrey_complex"
|
||||
@@ -1,332 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,76 +0,0 @@
|
||||
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),
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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': '房号单元'},
|
||||
),
|
||||
]
|
||||
@@ -1,528 +0,0 @@
|
||||
# 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='单位:m²', 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='单位:m²', 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='房号'),
|
||||
),
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
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",
|
||||
]
|
||||
@@ -1,841 +0,0 @@
|
||||
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="单位:m²",
|
||||
)
|
||||
plot_area = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="小区占地面积",
|
||||
help_text="单位:m²",
|
||||
)
|
||||
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"]
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
app_name = "complex"
|
||||
|
||||
urlpatterns: list = []
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrgConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.org"
|
||||
label = "org"
|
||||
@@ -1,300 +0,0 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-29 08:42
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrgUnit',
|
||||
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=100)),
|
||||
('type', models.CharField(choices=[('company', '公司'), ('division', '事业部'), ('region', '大区'), ('area', '区域'), ('district', '片区'), ('store', '门店'), ('group', '店组'), ('functional', '职能部门')], max_length=20)),
|
||||
('path', models.TextField(help_text='Materialized path: /root_id/.../self_id/ for subtree queries.')),
|
||||
('depth', models.SmallIntegerField(default=0)),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('attribute', models.CharField(blank=True, choices=[('direct', '直营'), ('franchise', '加盟')], max_length=10, null=True)),
|
||||
('address_city', models.CharField(blank=True, default='', max_length=50)),
|
||||
('address_district', models.CharField(blank=True, default='', max_length=50)),
|
||||
('address_detail', models.CharField(blank=True, default='', max_length=200)),
|
||||
('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)),
|
||||
('established_at', models.DateField(blank=True, null=True)),
|
||||
('phone', models.CharField(blank=True, default='', max_length=30)),
|
||||
('ext_start', models.IntegerField(blank=True, null=True)),
|
||||
('ext_end', models.IntegerField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'org_units',
|
||||
'ordering': ['sort_order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Staff',
|
||||
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)),
|
||||
('nickname', models.CharField(blank=True, default='', max_length=50)),
|
||||
('employee_no', models.CharField(blank=True, max_length=30, null=True, unique=True)),
|
||||
('role', models.CharField(choices=[('agent', '经纪人'), ('store_manager', '店长'), ('area_manager', '区域经理'), ('admin', '系统管理员'), ('operator', '运营/行政'), ('system', '系统账号')], max_length=30)),
|
||||
('job_title', models.CharField(blank=True, default='', max_length=100)),
|
||||
('job_category', models.CharField(blank=True, default='', help_text="Job classification (e.g. '置业顾问' = agent qualification flag).", max_length=50)),
|
||||
('job_level', models.SmallIntegerField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('active', '在职'), ('probation', '试用'), ('resigned', '离职'), ('frozen', '冻结')], default='active', max_length=20)),
|
||||
('phone_enc', models.BinaryField(blank=True, help_text='AES-256-GCM encrypted phone (DATA_MODEL_ORG §3.2).', null=True)),
|
||||
('phone_hash', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
|
||||
('phone_hide', models.BooleanField(default=False)),
|
||||
('email', models.EmailField(blank=True, default='', max_length=255)),
|
||||
('extension', models.CharField(blank=True, default='', max_length=20)),
|
||||
('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(blank=True, null=True)),
|
||||
('rejoined_at', models.DateField(blank=True, null=True)),
|
||||
('resigned_at', models.DateField(blank=True, null=True)),
|
||||
('joined_count', models.SmallIntegerField(default=1)),
|
||||
('industry_exp_years', models.SmallIntegerField(blank=True, null=True)),
|
||||
('business_type', models.CharField(blank=True, default='', max_length=50)),
|
||||
('bank_name', models.CharField(blank=True, default='', max_length=100)),
|
||||
('bank_account', models.CharField(blank=True, default='', max_length=50)),
|
||||
('partner_no', models.CharField(blank=True, default='', max_length=50)),
|
||||
('recruit_source', models.CharField(blank=True, default='', max_length=50)),
|
||||
('mentor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mentees', to='org.staff')),
|
||||
('org_unit', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='staff_members', to='org.orgunit')),
|
||||
('recruit_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recruited_staff', to='org.staff')),
|
||||
('referrer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referred_staff', to='org.staff')),
|
||||
('supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subordinates', to='org.staff')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffWorkExperience',
|
||||
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)),
|
||||
('company', models.CharField(max_length=200)),
|
||||
('job_title', models.CharField(blank=True, default='', max_length=100)),
|
||||
('start_date', models.DateField(blank=True, null=True)),
|
||||
('end_date', models.DateField(blank=True, null=True)),
|
||||
('reason', models.CharField(blank=True, default='', max_length=200)),
|
||||
('reference_name', models.CharField(blank=True, default='', max_length=50)),
|
||||
('reference_phone', models.CharField(blank=True, default='', max_length=30)),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_experiences', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_work_experiences',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffTraining',
|
||||
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)),
|
||||
('training_name', models.CharField(max_length=200)),
|
||||
('training_date', models.DateField(blank=True, null=True)),
|
||||
('certificate', models.CharField(blank=True, default='', max_length=200)),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trainings', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_trainings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffRewardPunish',
|
||||
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)),
|
||||
('rp_date', models.DateField()),
|
||||
('category', models.CharField(help_text='Configurable lookup_items domain: org.reward_punish_category.', max_length=50)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('remarks', models.TextField(blank=True, default='')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_reward_punish', to='org.staff')),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reward_punish_records', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_reward_punish',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffRemark',
|
||||
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)),
|
||||
('content', models.TextField()),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_remarks', to='org.staff')),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remarks', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_remarks',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffFamilyMember',
|
||||
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)),
|
||||
('relation', models.CharField(max_length=30)),
|
||||
('name', models.CharField(max_length=50)),
|
||||
('birthdate', models.DateField(blank=True, null=True)),
|
||||
('occupation', models.CharField(blank=True, default='', max_length=100)),
|
||||
('work_unit', models.CharField(blank=True, default='', max_length=200)),
|
||||
('phone_enc', models.BinaryField(blank=True, null=True)),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_members', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_family_members',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffEducation',
|
||||
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)),
|
||||
('stage', models.CharField(blank=True, default='', max_length=30)),
|
||||
('school', models.CharField(max_length=200)),
|
||||
('major', models.CharField(blank=True, default='', max_length=100)),
|
||||
('start_date', models.DateField(blank=True, null=True)),
|
||||
('end_date', models.DateField(blank=True, null=True)),
|
||||
('enrollment_status', models.CharField(blank=True, default='', max_length=30)),
|
||||
('degree', models.CharField(blank=True, default='', max_length=30)),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_educations',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffAccount',
|
||||
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)),
|
||||
('platform', models.CharField(choices=[('fonrey', '房睿主账号'), ('58anjuke', '58安居客'), ('cnreic', '中国网络经纪人'), ('wechat_mp', '微信公众号')], max_length=30)),
|
||||
('account_no', models.CharField(blank=True, default='', max_length=100)),
|
||||
('is_real_name_match', models.BooleanField(blank=True, null=True)),
|
||||
('is_bound', models.BooleanField(default=False)),
|
||||
('bound_at', models.DateTimeField(blank=True, null=True)),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_accounts', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_accounts',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orgunit',
|
||||
name='manager',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_org_units', to='org.staff'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orgunit',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='children', to='org.orgunit'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffTransferLog',
|
||||
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)),
|
||||
('transfer_type', models.CharField(choices=[('onboard', '入职'), ('transfer', '调动'), ('resign', '离职'), ('rejoin', '复职'), ('supervisor_change', '上级变更'), ('role_change', '角色变更'), ('freeze', '冻结账号'), ('unfreeze', '恢复账号')], max_length=30)),
|
||||
('old_value', models.JSONField(blank=True, null=True)),
|
||||
('new_value', models.JSONField(blank=True, null=True)),
|
||||
('transfer_date', models.DateField()),
|
||||
('remarks', models.CharField(blank=True, default='', max_length=50)),
|
||||
('operated_at', models.DateTimeField(auto_now_add=True)),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='operated_transfers', to='org.staff')),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='transfer_logs', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_transfer_logs',
|
||||
'indexes': [models.Index(fields=['staff', '-transfer_date'], name='idx_transfer_logs_staff'), models.Index(fields=['transfer_type', '-operated_at'], name='idx_transfer_logs_type'), models.Index(fields=['operator'], name='idx_transfer_logs_operator')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffPersonalInfo',
|
||||
fields=[
|
||||
('staff', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='personal_info', serialize=False, to='org.staff')),
|
||||
('gender', models.CharField(blank=True, choices=[('male', '男'), ('female', '女'), ('unknown', '未知')], default='', max_length=10)),
|
||||
('id_type', models.CharField(blank=True, choices=[('id_card', '身份证'), ('passport', '护照'), ('other', '其他')], default='', max_length=20)),
|
||||
('id_number_enc', models.BinaryField(blank=True, null=True)),
|
||||
('id_number_hash', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
|
||||
('id_verified', models.BooleanField(default=False)),
|
||||
('id_verified_at', models.DateTimeField(blank=True, null=True)),
|
||||
('birthdate', models.DateField(blank=True, null=True)),
|
||||
('native_place', models.CharField(blank=True, default='', max_length=100)),
|
||||
('domicile_type', models.CharField(blank=True, default='', max_length=20)),
|
||||
('marital_status', models.CharField(blank=True, default='', max_length=20)),
|
||||
('political_status', models.CharField(blank=True, default='', max_length=20)),
|
||||
('has_children', models.BooleanField(blank=True, null=True)),
|
||||
('education_level', models.CharField(blank=True, default='', max_length=20)),
|
||||
('ethnicity', models.CharField(blank=True, default='', max_length=20)),
|
||||
('domicile_address', models.CharField(blank=True, default='', max_length=200)),
|
||||
('residence_address', models.CharField(blank=True, default='', max_length=200)),
|
||||
('work_start_date', models.DateField(blank=True, null=True)),
|
||||
('emergency_contact', models.CharField(blank=True, default='', max_length=50)),
|
||||
('emergency_phone_enc', models.BinaryField(blank=True, null=True)),
|
||||
('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_personal_info', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_personal_info',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='staffaccount',
|
||||
constraint=models.UniqueConstraint(fields=('staff', 'platform'), name='uq_staff_accounts_staff_platform'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staff',
|
||||
index=models.Index(fields=['org_unit'], name='idx_staff_org_unit'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staff',
|
||||
index=models.Index(fields=['supervisor'], name='idx_staff_supervisor'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staff',
|
||||
index=models.Index(fields=['status'], name='idx_staff_status'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orgunit',
|
||||
index=models.Index(fields=['parent'], name='idx_org_units_parent'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orgunit',
|
||||
index=models.Index(fields=['type'], name='idx_org_units_type'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orgunit',
|
||||
index=models.Index(fields=['path'], name='idx_org_units_path'),
|
||||
),
|
||||
]
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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': '工作经历'},
|
||||
),
|
||||
]
|
||||
@@ -1,626 +0,0 @@
|
||||
# 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='开始日期'),
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
from apps.org.models.org_unit import OrgUnit
|
||||
from apps.org.models.staff import Staff, StaffPersonalInfo
|
||||
from apps.org.models.staff_logs import (
|
||||
StaffAccount,
|
||||
StaffEducation,
|
||||
StaffFamilyMember,
|
||||
StaffRemark,
|
||||
StaffRewardPunish,
|
||||
StaffTraining,
|
||||
StaffTransferLog,
|
||||
StaffWorkExperience,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OrgUnit",
|
||||
"Staff",
|
||||
"StaffPersonalInfo",
|
||||
"StaffAccount",
|
||||
"StaffEducation",
|
||||
"StaffFamilyMember",
|
||||
"StaffRemark",
|
||||
"StaffRewardPunish",
|
||||
"StaffTraining",
|
||||
"StaffTransferLog",
|
||||
"StaffWorkExperience",
|
||||
]
|
||||
@@ -1,133 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import OrgUnitAttribute, OrgUnitType
|
||||
from core.models.base import SoftDeleteModel
|
||||
|
||||
|
||||
class OrgUnit(SoftDeleteModel):
|
||||
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,
|
||||
blank=True,
|
||||
on_delete=models.RESTRICT,
|
||||
related_name="children",
|
||||
db_index=True,
|
||||
verbose_name="父节点",
|
||||
help_text="父节点,根节点为 NULL",
|
||||
)
|
||||
path = models.TextField(
|
||||
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="同级排序",
|
||||
)
|
||||
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",
|
||||
)
|
||||
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=已关闭部门,仍可在筛选中显示",
|
||||
)
|
||||
|
||||
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"),
|
||||
models.Index(fields=["path"], name="idx_org_units_path"),
|
||||
]
|
||||
ordering = ["sort_order", "name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.type})"
|
||||
@@ -1,365 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from core.enums import StaffGender, StaffIdType, StaffRole, StaffStatus
|
||||
from core.models.base import SoftDeleteModel
|
||||
|
||||
|
||||
class Staff(SoftDeleteModel):
|
||||
org_unit = models.ForeignKey(
|
||||
"org.OrgUnit",
|
||||
on_delete=models.RESTRICT,
|
||||
related_name="staff_members",
|
||||
db_index=True,
|
||||
verbose_name="所属组织节点",
|
||||
help_text="当前所属组织节点(门店或店组)",
|
||||
)
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
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='如「高级业务员」',
|
||||
)
|
||||
job_category = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="职务类别",
|
||||
help_text='如「置业顾问」(经纪人判定字段)',
|
||||
)
|
||||
job_level = models.SmallIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="职级",
|
||||
)
|
||||
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,
|
||||
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="单位:年",
|
||||
)
|
||||
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="联号",
|
||||
)
|
||||
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="招聘来源",
|
||||
)
|
||||
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"),
|
||||
models.Index(fields=["status"], name="idx_staff_status"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class StaffPersonalInfo(models.Model):
|
||||
staff = models.OneToOneField(
|
||||
"org.Staff",
|
||||
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="最后更新时间",
|
||||
)
|
||||
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 = "员工个人信息"
|
||||
@@ -1,354 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import StaffAccountPlatform, StaffTransferType
|
||||
from core.models.base import SoftDeleteModel, TimeStampedModel
|
||||
|
||||
|
||||
class StaffTransferLog(TimeStampedModel):
|
||||
staff = models.ForeignKey(
|
||||
"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字",
|
||||
)
|
||||
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="系统操作时间",
|
||||
)
|
||||
|
||||
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"),
|
||||
models.Index(fields=["operator"], name="idx_transfer_logs_operator"),
|
||||
]
|
||||
|
||||
|
||||
class StaffRewardPunish(SoftDeleteModel):
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reward_punish_records",
|
||||
verbose_name="被奖惩员工",
|
||||
)
|
||||
rp_date = models.DateField(
|
||||
verbose_name="奖惩日期",
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
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="备注",
|
||||
)
|
||||
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):
|
||||
staff = models.ForeignKey(
|
||||
"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="绑定时间",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_accounts"
|
||||
verbose_name = "员工第三方账号"
|
||||
verbose_name_plural = "员工第三方账号"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["staff", "platform"],
|
||||
name="uq_staff_accounts_staff_platform",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class StaffWorkExperience(TimeStampedModel):
|
||||
staff = models.ForeignKey(
|
||||
"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="证明人电话",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_work_experiences"
|
||||
verbose_name = "工作经历"
|
||||
verbose_name_plural = "工作经历"
|
||||
|
||||
|
||||
class StaffEducation(TimeStampedModel):
|
||||
staff = models.ForeignKey(
|
||||
"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="学位",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_educations"
|
||||
verbose_name = "教育经历"
|
||||
verbose_name_plural = "教育经历"
|
||||
|
||||
|
||||
class StaffTraining(TimeStampedModel):
|
||||
staff = models.ForeignKey(
|
||||
"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="证书",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_trainings"
|
||||
verbose_name = "培训记录"
|
||||
verbose_name_plural = "培训记录"
|
||||
|
||||
|
||||
class StaffFamilyMember(TimeStampedModel):
|
||||
staff = models.ForeignKey(
|
||||
"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 加密",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_family_members"
|
||||
verbose_name = "家庭成员"
|
||||
verbose_name_plural = "家庭成员"
|
||||
|
||||
|
||||
class StaffRemark(SoftDeleteModel):
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="remarks",
|
||||
verbose_name="所属员工",
|
||||
)
|
||||
content = models.TextField(
|
||||
verbose_name="备注内容",
|
||||
)
|
||||
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 = "员工备注"
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
app_name = "org"
|
||||
|
||||
urlpatterns: list = []
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PermissionConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.permission"
|
||||
label = "fonrey_permission"
|
||||
@@ -1,249 +0,0 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-29 08:47
|
||||
|
||||
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'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PermissionChangeLog',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('target_type', models.CharField(choices=[('role', '角色'), ('role_permission', '角色权限'), ('staff_role', '员工角色'), ('staff_override', '员工权限覆盖'), ('staff_scope', '员工数据范围')], max_length=30)),
|
||||
('target_id', models.UUIDField()),
|
||||
('permission_code', models.CharField(blank=True, default='', max_length=150)),
|
||||
('action', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除'), ('assign', '分配'), ('revoke', '撤销')], max_length=20)),
|
||||
('old_value', models.JSONField(blank=True, null=True)),
|
||||
('new_value', models.JSONField(blank=True, null=True)),
|
||||
('operator_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True, default='')),
|
||||
('reason', models.TextField(blank=True, default='')),
|
||||
('operated_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'permission_change_logs',
|
||||
'ordering': ['-operated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PermissionDef',
|
||||
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)),
|
||||
('code', models.CharField(max_length=150, unique=True)),
|
||||
('module', models.CharField(choices=[('home', '首页'), ('property', '房源'), ('new_house', '新房'), ('client', '客源'), ('transaction', '交易'), ('data', '数据'), ('marketing', '营销'), ('hr', '人事OA'), ('contract', '合同'), ('trinet', '三网'), ('system', '系统'), ('mobile', '移动端'), ('smart_store', '智能门店'), ('recharge', '在线充值')], max_length=50)),
|
||||
('sub_module', models.CharField(blank=True, default='', max_length=50)),
|
||||
('group_name', models.CharField(max_length=100)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('value_type', models.CharField(choices=[('boolean', '开关型'), ('scope', '范围型'), ('integer', '数值型')], max_length=20)),
|
||||
('scope_choices', models.JSONField(blank=True, default=list)),
|
||||
('integer_min', models.IntegerField(blank=True, null=True)),
|
||||
('integer_max', models.IntegerField(blank=True, null=True)),
|
||||
('default_value', models.JSONField(default=dict)),
|
||||
('max_allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), blank=True, default=list, size=None)),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_deprecated', models.BooleanField(default=False)),
|
||||
('version', models.PositiveIntegerField(default=1)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'permission_defs',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Role',
|
||||
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=100)),
|
||||
('category', models.CharField(choices=[('agent', '置业顾问'), ('store_manager', '店管'), ('director', '总经'), ('operator', '运营/行政'), ('custom', '自定义')], max_length=30)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('is_system_builtin', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_roles_created', to='org.staff')),
|
||||
('template_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_roles', to='fonrey_permission.role')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_roles_updated', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'roles',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffRole',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('is_primary', models.BooleanField(default=False)),
|
||||
('assigned_at', models.DateTimeField(auto_now_add=True)),
|
||||
('valid_from', models.DateField(blank=True, null=True)),
|
||||
('valid_until', models.DateField(blank=True, null=True)),
|
||||
('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_role_assignments_made', to='org.staff')),
|
||||
('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='staff_links', to='fonrey_permission.role')),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_roles', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_roles',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffPermissionOverride',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('value', models.JSONField()),
|
||||
('override_mode', models.CharField(choices=[('replace', '覆盖'), ('restrict', '限制'), ('grant', '授予')], default='replace', max_length=10)),
|
||||
('reason', models.TextField(blank=True, default='')),
|
||||
('modified_at', models.DateTimeField(auto_now=True)),
|
||||
('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_overrides_modified', to='org.staff')),
|
||||
('permission_def', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='staff_overrides', to='fonrey_permission.permissiondef')),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_overrides', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_permission_overrides',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StaffDataScope',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('scope_type', models.CharField(choices=[('self', '本人'), ('group', '本组'), ('store', '本门店'), ('area', '本区域'), ('region', '本大区'), ('company', '全公司'), ('custom_unit', '自定义组织单元')], max_length=20)),
|
||||
('is_readable', models.BooleanField(default=True)),
|
||||
('is_writable', models.BooleanField(default=False)),
|
||||
('granted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('reason', models.TextField(blank=True, default='')),
|
||||
('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='data_scopes_granted', to='org.staff')),
|
||||
('org_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='data_scope_grants', to='org.orgunit')),
|
||||
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='data_scopes', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'staff_data_scopes',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RolePermission',
|
||||
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)),
|
||||
('value', models.JSONField()),
|
||||
('permission_def', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='role_assignments', to='fonrey_permission.permissiondef')),
|
||||
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='fonrey_permission.role')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='role_permissions_updated', to='org.staff')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'role_permissions',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='permissiondef',
|
||||
index=models.Index(condition=models.Q(('is_active', True)), fields=['module', 'sub_module', 'sort_order'], name='idx_perm_defs_module'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='permissiondef',
|
||||
index=models.Index(condition=models.Q(('is_active', True)), fields=['is_active'], name='idx_perm_defs_active'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='permissionchangelog',
|
||||
name='operator',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='permission_changes_operated', to='org.staff'),
|
||||
),
|
||||
migrations.AddField(
|
||||
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'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='permissionchangelog',
|
||||
name='staff',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='permission_change_logs_affecting', to='org.staff'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staffrole',
|
||||
index=models.Index(fields=['role'], name='idx_staff_roles_role'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='staffrole',
|
||||
constraint=models.UniqueConstraint(fields=('staff', 'role'), name='uq_staff_roles'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='staffrole',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('staff',), name='uq_staff_roles_primary'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staffpermissionoverride',
|
||||
index=models.Index(fields=['staff'], name='idx_staff_overrides_staff'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='staffpermissionoverride',
|
||||
constraint=models.UniqueConstraint(fields=('staff', 'permission_def'), name='uq_staff_overrides'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staffdatascope',
|
||||
index=models.Index(fields=['staff'], name='idx_data_scopes_staff'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staffdatascope',
|
||||
index=models.Index(fields=['org_unit'], name='idx_data_scopes_org'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staffdatascope',
|
||||
index=models.Index(condition=models.Q(('expires_at__isnull', False)), fields=['expires_at'], name='idx_data_scopes_expires'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='rolepermission',
|
||||
index=models.Index(fields=['role'], name='idx_role_permissions_role'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='rolepermission',
|
||||
index=models.Index(fields=['permission_def'], name='idx_role_permissions_def'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='rolepermission',
|
||||
constraint=models.UniqueConstraint(fields=('role', 'permission_def'), name='uq_role_permissions'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='role',
|
||||
index=models.Index(condition=models.Q(('deleted_at__isnull', True)), fields=['category'], name='idx_roles_category'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='role',
|
||||
index=models.Index(fields=['template_role'], name='idx_roles_template'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='role',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('name',), name='uq_roles_name_active'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='permissionchangelog',
|
||||
index=models.Index(condition=models.Q(('staff__isnull', False)), fields=['staff', '-operated_at'], name='idx_perm_log_staff'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='permissionchangelog',
|
||||
index=models.Index(condition=models.Q(('role__isnull', False)), fields=['role', '-operated_at'], name='idx_perm_log_role'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='permissionchangelog',
|
||||
index=models.Index(fields=['target_type', 'target_id', '-operated_at'], name='idx_perm_log_target'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='permissionchangelog',
|
||||
index=models.Index(fields=['operator', '-operated_at'], name='idx_perm_log_operator'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='permissionchangelog',
|
||||
index=models.Index(fields=['-operated_at'], name='idx_perm_log_time'),
|
||||
),
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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': '员工角色'},
|
||||
),
|
||||
]
|
||||
@@ -1,336 +0,0 @@
|
||||
# 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='失效日'),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-30 04:44
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fonrey_permission_def', '0001_initial'),
|
||||
('fonrey_permission', '0003_alter_permissionchangelog_action_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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_def.permissiondef', 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_def.permissiondef', verbose_name='被覆盖权限项'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='PermissionDef',
|
||||
),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
from apps.permission.models.role import Role, RolePermission
|
||||
from apps.permission.models.staff_perm import (
|
||||
PermissionChangeLog,
|
||||
StaffDataScope,
|
||||
StaffPermissionOverride,
|
||||
StaffRole,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PermissionChangeLog",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"StaffDataScope",
|
||||
"StaffPermissionOverride",
|
||||
"StaffRole",
|
||||
]
|
||||
@@ -1,129 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import PermissionRoleCategory
|
||||
from core.models.base import SoftDeleteModel, TimeStampedModel
|
||||
|
||||
|
||||
class Role(SoftDeleteModel):
|
||||
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=禁用(员工无法继承该角色权限)",
|
||||
)
|
||||
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",
|
||||
null=True,
|
||||
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"],
|
||||
name="uq_roles_name_active",
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["category"],
|
||||
name="idx_roles_category",
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
),
|
||||
models.Index(fields=["template_role"], name="idx_roles_template"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.category})"
|
||||
|
||||
|
||||
class RolePermission(TimeStampedModel):
|
||||
role = models.ForeignKey(
|
||||
"fonrey_permission.Role",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="permissions",
|
||||
verbose_name="所属角色",
|
||||
help_text="稀疏存储:角色删除时级联清理权限值",
|
||||
)
|
||||
permission_def = models.ForeignKey(
|
||||
"fonrey_permission_def.PermissionDef",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="role_assignments",
|
||||
verbose_name="权限定义",
|
||||
help_text="RESTRICT 防止删除仍被引用的权限项",
|
||||
)
|
||||
value = models.JSONField(
|
||||
verbose_name="权限值",
|
||||
help_text='统一格式 {"v": <value>}',
|
||||
)
|
||||
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"],
|
||||
name="uq_role_permissions",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["role"], name="idx_role_permissions_role"),
|
||||
models.Index(fields=["permission_def"], name="idx_role_permissions_def"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.role.name} → {self.permission_def.code}"
|
||||
@@ -1,315 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import (
|
||||
PermissionChangeAction,
|
||||
PermissionChangeTargetType,
|
||||
PermissionDataScopeType,
|
||||
PermissionOverrideMode,
|
||||
)
|
||||
from core.models.base import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class StaffRole(UUIDPrimaryKeyModel):
|
||||
staff = models.ForeignKey(
|
||||
"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="分配时间",
|
||||
)
|
||||
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="失效日",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_roles"
|
||||
verbose_name = "员工角色"
|
||||
verbose_name_plural = "员工角色"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["staff", "role"],
|
||||
name="uq_staff_roles",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["staff"],
|
||||
condition=models.Q(is_primary=True),
|
||||
name="uq_staff_roles_primary",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["role"], name="idx_staff_roles_role"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
marker = " [primary]" if self.is_primary else ""
|
||||
return f"{self.staff_id} → {self.role_id}{marker}"
|
||||
|
||||
|
||||
class StaffPermissionOverride(UUIDPrimaryKeyModel):
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="permission_overrides",
|
||||
verbose_name="所属员工",
|
||||
help_text="员工删除时级联删除覆盖记录",
|
||||
)
|
||||
permission_def = models.ForeignKey(
|
||||
"fonrey_permission_def.PermissionDef",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="staff_overrides",
|
||||
verbose_name="被覆盖权限项",
|
||||
)
|
||||
value = models.JSONField(
|
||||
verbose_name="个人权限值",
|
||||
help_text='统一格式 {"v": <value>}',
|
||||
)
|
||||
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="管理员备注,建议强制填写以便审计",
|
||||
)
|
||||
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="最近修改时间",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_permission_overrides"
|
||||
verbose_name = "个人权限覆盖"
|
||||
verbose_name_plural = "个人权限覆盖"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["staff", "permission_def"],
|
||||
name="uq_staff_overrides",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["staff"], name="idx_staff_overrides_staff"),
|
||||
]
|
||||
|
||||
|
||||
class StaffDataScope(UUIDPrimaryKeyModel):
|
||||
staff = models.ForeignKey(
|
||||
"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",
|
||||
null=True,
|
||||
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="默认只读",
|
||||
)
|
||||
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="授予原因",
|
||||
)
|
||||
|
||||
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"),
|
||||
models.Index(
|
||||
fields=["expires_at"],
|
||||
name="idx_data_scopes_expires",
|
||||
condition=models.Q(expires_at__isnull=False),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
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",
|
||||
null=True,
|
||||
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="变更后快照",
|
||||
)
|
||||
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 流水,分区键",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "permission_change_logs"
|
||||
verbose_name = "权限变更流水"
|
||||
verbose_name_plural = "权限变更流水"
|
||||
ordering = ["-operated_at"]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["staff", "-operated_at"],
|
||||
name="idx_perm_log_staff",
|
||||
condition=models.Q(staff__isnull=False),
|
||||
),
|
||||
models.Index(
|
||||
fields=["role", "-operated_at"],
|
||||
name="idx_perm_log_role",
|
||||
condition=models.Q(role__isnull=False),
|
||||
),
|
||||
models.Index(
|
||||
fields=["target_type", "target_id", "-operated_at"],
|
||||
name="idx_perm_log_target",
|
||||
),
|
||||
models.Index(
|
||||
fields=["operator", "-operated_at"],
|
||||
name="idx_perm_log_operator",
|
||||
),
|
||||
models.Index(fields=["-operated_at"], name="idx_perm_log_time"),
|
||||
]
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
raise NotImplementedError("PermissionChangeLog is append-only and cannot be deleted.")
|
||||
@@ -1,3 +0,0 @@
|
||||
from apps.permission.services.seed_default_roles import seed_default_roles
|
||||
|
||||
__all__ = ["seed_default_roles"]
|
||||
@@ -1,218 +0,0 @@
|
||||
import logging
|
||||
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ROLES = [
|
||||
{"name": "置业顾问", "category": "agent"},
|
||||
{"name": "店管", "category": "store_manager"},
|
||||
{"name": "区管", "category": "custom"},
|
||||
{"name": "区总", "category": "custom"},
|
||||
{"name": "副总", "category": "custom"},
|
||||
{"name": "总经", "category": "director"},
|
||||
{"name": "其他职能", "category": "operator"},
|
||||
]
|
||||
|
||||
_T = True
|
||||
_F = False
|
||||
_SELF = "self"
|
||||
_DEPT = "dept"
|
||||
_ALL = "all"
|
||||
_NONE = "none"
|
||||
|
||||
_MATRIX = {
|
||||
"property.listing.create": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.view_scope": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.listing.view_public": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.view_private": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.set_public": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.set_private": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.set_locked": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.set_special": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.delete": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.restore": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.export": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.edit_description": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.view_deal": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.price_read": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.view_history": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.view_owner_others": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.set_protected": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.view_protected": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.listing.change_keeper": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.listing.merge_duplicate": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.status_sold": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.grade_set_a": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.listing.grade_set_e": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.contact.view_phone": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.contact.view_phone_limit": [20, -1, -1, -1, -1, -1, 0],
|
||||
"property.contact.add_contact": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.contact.edit_core": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.contact.edit_basic": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.contact.delete_contact": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.contact.view_cert": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.contact.view_operation_log":[_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.address.view_detail": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.address.view_limit": [10, -1, -1, -1, -1, -1, 0],
|
||||
"property.address.edit": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.create": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.key.edit": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.return": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.view_password": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.view_number": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.borrow": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.give_back": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.delete": [_NONE, _SELF, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.key.export": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.survey.create_photo": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.survey.download_photo": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.survey.delete_photo": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.survey.create": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.survey.view": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.survey.upload_video": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.survey.download_video": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.survey.play_video": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.mandate.create": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.mandate.renew": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.mandate.view": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.mandate.revoke": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.mandate.export": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"property.follow.view_scope": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.follow.hide": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.follow.view_hidden": [_NONE, _SELF, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.follow.pin": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.attachment.create": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.attachment.view": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.attachment.edit": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.attachment.download": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"property.attachment.delete": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"property.showing.view_scope": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.private.create": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"client.private.view": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.private.view_protected": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.private.edit": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.private.edit_protected": [_SELF, _SELF, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.private.set_protected": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.private.to_public": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.private.export": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.public.view": [_NONE, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.public.to_private": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"client.public.edit": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"client.public.change_status": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.deal.view": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.deal.view_public": [_NONE, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.deal.re_transaction": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"client.deal.export": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.contact.view_phone_private": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.contact.view_phone_protected":[_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.contact.view_phone_public": [_NONE, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.contact.view_phone_limit": [20, -1, -1, -1, -1, -1, 0],
|
||||
"client.contact.edit_contact": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.contact.edit_phone": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.mgmt.delete": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.mgmt.to_deal": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.mgmt.change_staff": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.mgmt.batch_change_staff": [_NONE, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.mgmt.view_operation_log": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.mgmt.merge_private": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"client.showing.create": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"client.showing.view": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.showing.edit": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.archive.view": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"client.archive.import": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.archive.view_phone": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.archive.delete": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"client.archive.view_log": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"home.dashboard.view_version": [_T, _T, _T, _T, _T, _T, _T],
|
||||
"home.dashboard.personal_rank": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"home.dashboard.dept_rank": [_NONE, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"home.dashboard.manage_praise": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.view": [_T, _T, _T, _T, _T, _T, _T],
|
||||
"complex.view_structure": [_T, _T, _T, _T, _T, _T, _T],
|
||||
"complex.create": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.create_unit": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.edit": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.edit_unit": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.delete": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"complex.delete_unit": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.delete_with_property": [_F, _F, _F, _T, _T, _T, _F],
|
||||
"complex.merge": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"complex.move_unit": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"complex.lock": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.view_deal": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"complex.view_deal_detail": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.view_address_scope": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"complex.region_manage": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"complex.material.view_photo": [_T, _T, _T, _T, _T, _T, _T],
|
||||
"complex.material.manage_photo": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"complex.material.delete_photo": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.material.download_photo": [_T, _T, _T, _T, _T, _T, _T],
|
||||
"complex.material.view_attachment": [_T, _T, _T, _T, _T, _T, _T],
|
||||
"complex.material.manage_attachment": [_T, _T, _T, _T, _T, _T, _F],
|
||||
"complex.material.download_attachment":[_T, _T, _T, _T, _T, _T, _T],
|
||||
"complex.material.delete_attachment": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"complex.material.view_surrounding": [_T, _T, _T, _T, _T, _T, _T],
|
||||
"complex.feedback.view": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"complex.feedback.handle": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"org.view_structure": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _SELF],
|
||||
"org.view_dept": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"org.edit_dept": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"org.view_staff": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"org.edit_staff": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"org.edit_staff_detail": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"org.freeze_account": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"org.import_staff": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"org.export_staff": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"org.view_permission": [_F, _F, _F, _T, _T, _T, _F],
|
||||
"org.edit_permission": [_F, _F, _F, _T, _T, _T, _F],
|
||||
"org.export_permission": [_F, _F, _F, _T, _T, _T, _F],
|
||||
"org.edit_position": [_F, _F, _F, _T, _T, _T, _F],
|
||||
"org.edit_role": [_F, _F, _F, _T, _T, _T, _F],
|
||||
"org.view_store_list": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"org.export_store_list": [_F, _F, _T, _T, _T, _T, _F],
|
||||
"org.view_contact_book": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _SELF],
|
||||
"org.transfer_business": [_NONE, _DEPT, _DEPT, _ALL, _ALL, _ALL, _NONE],
|
||||
"org.resign_apply": [_SELF, _DEPT, _DEPT, _ALL, _ALL, _ALL, _SELF],
|
||||
"org.invite_onboard": [_F, _T, _T, _T, _T, _T, _F],
|
||||
"org.view_contact_phone_limit": [5, -1, -1, -1, -1, -1, 5],
|
||||
}
|
||||
|
||||
|
||||
def seed_default_roles(schema_name: str) -> None:
|
||||
from django.apps import apps
|
||||
|
||||
Role = apps.get_model("fonrey_permission", "Role")
|
||||
RolePermission = apps.get_model("fonrey_permission", "RolePermission")
|
||||
PermissionDef = apps.get_model("fonrey_permission_def", "PermissionDef")
|
||||
|
||||
perm_map = {p.code: p for p in PermissionDef.objects.all()}
|
||||
|
||||
roles = []
|
||||
for role_def in _ROLES:
|
||||
role, _ = Role.objects.get_or_create(
|
||||
name=role_def["name"],
|
||||
defaults={
|
||||
"category": role_def["category"],
|
||||
"is_system_builtin": True,
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
roles.append(role)
|
||||
|
||||
rp_objects = []
|
||||
for code, values in _MATRIX.items():
|
||||
perm = perm_map.get(code)
|
||||
if perm is None:
|
||||
logger.warning("PermissionDef not found: %s", code)
|
||||
continue
|
||||
for role, val in zip(roles, values):
|
||||
rp_objects.append(
|
||||
RolePermission(
|
||||
role=role,
|
||||
permission_def=perm,
|
||||
value={"v": val},
|
||||
)
|
||||
)
|
||||
|
||||
RolePermission.objects.bulk_create(rp_objects, ignore_conflicts=True)
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
app_name = "permission"
|
||||
|
||||
urlpatterns: list = []
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PermissionDefConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.permission_def"
|
||||
label = "fonrey_permission_def"
|
||||
verbose_name = "权限定义(全局共享)"
|
||||
@@ -1,46 +0,0 @@
|
||||
# Generated by Django 4.2.16 on 2026-04-30 04:41
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PermissionDef',
|
||||
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)),
|
||||
('code', models.CharField(help_text='规则:{module}.{sub_module}.{action}[.{qualifier}]', max_length=150, unique=True, verbose_name='权限编码')),
|
||||
('module', 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='一级模块')),
|
||||
('sub_module', models.CharField(blank=True, default='', help_text='如「二手&租赁」「商圈精耕」', max_length=50, verbose_name='二级模块')),
|
||||
('group_name', models.CharField(help_text='如「私客基础权限」「联系人基础权限」', max_length=100, verbose_name='分组标题')),
|
||||
('name', models.CharField(max_length=200, verbose_name='显示名称')),
|
||||
('description', models.TextField(blank=True, default='', verbose_name='权限作用描述')),
|
||||
('value_type', models.CharField(choices=[('boolean', '开关型'), ('scope', '范围型'), ('integer', '数值型')], help_text='BOOLEAN=开关型 / SCOPE=范围型 / INTEGER=数值型', max_length=20, verbose_name='权限值类型')),
|
||||
('scope_choices', models.JSONField(blank=True, default=list, help_text='仅 SCOPE 类型有效,可选枚举 code 列表,如 ["none","self","store","company"]', verbose_name='可选范围')),
|
||||
('integer_min', models.IntegerField(blank=True, help_text='仅 INTEGER 类型有效', null=True, verbose_name='最小值')),
|
||||
('integer_max', models.IntegerField(blank=True, help_text='仅 INTEGER 类型有效;NULL=无上限(业务上 0 通常代表不限制)', null=True, verbose_name='最大值')),
|
||||
('default_value', models.JSONField(default=dict, help_text='系统最小默认值,格式 {"v": <value>}', verbose_name='默认值')),
|
||||
('max_allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), blank=True, default=list, help_text='允许配置此权限的角色类别列表,空数组=所有类别均可', size=None, verbose_name='可配置角色类别')),
|
||||
('sort_order', models.PositiveIntegerField(default=0, help_text='分组内排序', verbose_name='排序顺序')),
|
||||
('is_active', models.BooleanField(default=True, help_text='下线权限项置 FALSE,历史记录保留', verbose_name='是否启用')),
|
||||
('is_deprecated', models.BooleanField(default=False, help_text='不再推荐使用但保持兼容', verbose_name='是否废弃')),
|
||||
('version', models.PositiveIntegerField(default=1, help_text='变更时递增,用于缓存失效', verbose_name='定义版本')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '权限定义',
|
||||
'verbose_name_plural': '权限定义',
|
||||
'db_table': 'permission_defs',
|
||||
'indexes': [models.Index(condition=models.Q(('is_active', True)), fields=['module', 'sub_module', 'sort_order'], name='idx_perm_defs_module'), models.Index(condition=models.Q(('is_active', True)), fields=['is_active'], name='idx_perm_defs_active')],
|
||||
},
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user