diff --git a/TempLate/GERP/index.html b/TempLate/GERP/index.html
index 30021d9c..a585c831 100644
--- a/TempLate/GERP/index.html
+++ b/TempLate/GERP/index.html
@@ -5,7 +5,7 @@
GERP 管理系统
-
+
@@ -14,7 +14,7 @@
-
+
diff --git a/gvsds/apps/gerp/app_manager.py b/gvsds/apps/gerp/app_manager.py
index 0bcee476..f088c843 100644
--- a/gvsds/apps/gerp/app_manager.py
+++ b/gvsds/apps/gerp/app_manager.py
@@ -426,8 +426,15 @@ class AppManager:
]
@staticmethod
- def GetAppConfig(TenantUUID, AppUUID):
- """读取指定租户下应用配置(模板 + 已保存值)
+ def GetAppConfig(TenantUUID, AppUUID, CompanyUUID=None):
+ """读取指定租户(+可选公司)下应用配置(模板 + 已保存值)
+
+ 读取回退链:公司级配置 → 租户默认配置 → 模板默认值。
+
+ Args:
+ TenantUUID: 租户 UUID
+ AppUUID: 应用 UUID
+ CompanyUUID: 公司 UUID(可选);传公司时优先读公司配置,缺失键回退租户默认
Returns:
list[dict]:{Key, Label, Value, Default, Secret, Placeholder, Updated}
@@ -441,14 +448,21 @@ class AppManager:
return []
tenant_bytes = _ToBytes(TenantUUID)
app_bytes = _ToBytes(AppUUID)
- saved = {}
+ company_bytes = _ToBytes(CompanyUUID)
+ saved_company = {}
+ if company_bytes:
+ for cfg in AppConfig.objects.filter(
+ TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=company_bytes):
+ saved_company[cfg.ConfigKey] = cfg
+ saved_tenant = {}
if tenant_bytes:
- for cfg in AppConfig.objects.filter(TenantUUID=tenant_bytes, AppUUID=app_bytes):
- saved[cfg.ConfigKey] = cfg
+ for cfg in AppConfig.objects.filter(
+ TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True):
+ saved_tenant[cfg.ConfigKey] = cfg
result = []
for t in template:
key = t['Key']
- cfg = saved.get(key)
+ cfg = saved_company.get(key) or saved_tenant.get(key)
result.append({
'Key': key,
'Label': t['Label'],
@@ -461,13 +475,14 @@ class AppManager:
return result
@staticmethod
- def SaveAppConfig(TenantUUID, AppUUID, Updates, OperatorUUID=None):
- """保存租户应用配置(按模板键 upsert)
+ def SaveAppConfig(TenantUUID, AppUUID, Updates, CompanyUUID=None, OperatorUUID=None):
+ """保存应用配置(按模板键 upsert,支持租户默认/公司级)
Args:
TenantUUID: 租户 UUID
AppUUID: 应用 UUID
Updates: dict {Key: Value}
+ CompanyUUID: 公司 UUID(None=租户级默认配置)
OperatorUUID: 更新人 UUID(可选)
Returns:
@@ -483,6 +498,7 @@ class AppManager:
return False, '该应用未声明配置项', []
allowed = {t['Key'] for t in template}
tenant_bytes = _ToBytes(TenantUUID)
+ company_bytes = _ToBytes(CompanyUUID)
app_bytes = _ToBytes(AppUUID)
operator_bytes = _ToBytes(OperatorUUID)
updated = []
@@ -493,6 +509,7 @@ class AppManager:
value = str(value)
cfg, created = AppConfig.objects.get_or_create(
TenantUUID=tenant_bytes,
+ CompanyUUID=company_bytes,
AppUUID=app_bytes,
ConfigKey=key,
defaults={
@@ -510,6 +527,61 @@ class AppManager:
updated.append(key)
return True, '', updated
+ @staticmethod
+ def CopyAppConfig(TenantUUID, AppUUID, SourceCompanyUUID, TargetCompanyUUIDs, OperatorUUID=None):
+ """把源配置(租户默认或指定公司)批量复制到目标公司(覆盖目标同名键)
+
+ Args:
+ TenantUUID: 租户 UUID
+ AppUUID: 应用 UUID
+ SourceCompanyUUID: 源公司 UUID(None=以租户默认配置为源)
+ TargetCompanyUUIDs: 目标公司 UUID 列表(str/bytes/UUIDObj 均可)
+ OperatorUUID: 复制人 UUID(可选)
+
+ Returns:
+ (ok, err, copied_count)
+ """
+ import datetime as _dt
+ from .models import AppConfig
+ tenant_bytes = _ToBytes(TenantUUID)
+ app_bytes = _ToBytes(AppUUID)
+ source_bytes = _ToBytes(SourceCompanyUUID)
+ if source_bytes:
+ source_rows = list(AppConfig.objects.filter(
+ TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID=source_bytes))
+ else:
+ source_rows = list(AppConfig.objects.filter(
+ TenantUUID=tenant_bytes, AppUUID=app_bytes, CompanyUUID__isnull=True))
+ if not source_rows:
+ return False, '源配置不存在(无已保存的配置项)', 0
+ targets = [_ToBytes(c) for c in (TargetCompanyUUIDs or []) if _ToBytes(c)]
+ if not targets:
+ return False, '未指定目标公司', 0
+ operator_bytes = _ToBytes(OperatorUUID)
+ now = _dt.datetime.utcnow()
+ copied = 0
+ for company_bytes in targets:
+ for row in source_rows:
+ cfg, created = AppConfig.objects.get_or_create(
+ TenantUUID=tenant_bytes,
+ CompanyUUID=company_bytes,
+ AppUUID=app_bytes,
+ ConfigKey=row.ConfigKey,
+ defaults={
+ 'AppConfigUUID': uuid.uuid4().bytes,
+ 'ConfigValue': row.ConfigValue,
+ 'UpdateBy': operator_bytes,
+ 'UpdateTime': now,
+ },
+ )
+ if not created:
+ cfg.ConfigValue = row.ConfigValue
+ cfg.UpdateBy = operator_bytes
+ cfg.UpdateTime = now
+ cfg.save(update_fields=['ConfigValue', 'UpdateBy', 'UpdateTime'])
+ copied += 1
+ return True, '', copied
+
# ==================== 个人启用/申请/审批 ====================
@staticmethod
diff --git a/gvsds/apps/gerp/migrations/0027_appconfig_company.py b/gvsds/apps/gerp/migrations/0027_appconfig_company.py
new file mode 100644
index 00000000..02317417
--- /dev/null
+++ b/gvsds/apps/gerp/migrations/0027_appconfig_company.py
@@ -0,0 +1,28 @@
+"""Add CompanyUUID to AppConfig (per-company config, NULL = tenant default)
+
+应用配置从租户级扩展为公司级:
+- CompanyUUID 为空(NULL)→ 租户级默认配置(全租户共享)
+- CompanyUUID 有值 → 公司级配置(覆盖租户默认)
+唯一约束扩为 (tenant, company, app, key),MySQL BINARY(16) NULL 允许多条租户默认。
+"""
+from django.db import migrations
+import gvsds.apps.core.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('gerp', '0026_appconfig'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='appconfig',
+ name='company_uuid',
+ field=gvsds.apps.core.models.UUIDField(blank=True, db_column='CompanyUUID', null=True, verbose_name='公司UUID(空=租户级默认配置)'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='appconfig',
+ unique_together={('tenant_uuid', 'company_uuid', 'app_uuid', 'config_key')},
+ ),
+ ]
diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py
index 559a1aa1..fa0ad85b 100644
--- a/gvsds/apps/gerp/models.py
+++ b/gvsds/apps/gerp/models.py
@@ -829,7 +829,7 @@ class TenantApp(QModel):
class Meta:
db_table = 'TenantApp'
app_label = 'gerp'
- unique_together = (('tenant_uuid', 'app_uuid'),)
+ unique_together = (('TenantUUID', 'AppUUID'),)
verbose_name = '企业应用安装'
verbose_name_plural = '企业应用安装'
@@ -855,7 +855,7 @@ class CompanyApp(QModel):
class Meta:
db_table = 'CompanyApp'
app_label = 'gerp'
- unique_together = (('company_uuid', 'app_uuid'),)
+ unique_together = (('CompanyUUID', 'AppUUID'),)
verbose_name = '企业应用安装(公司维度)'
verbose_name_plural = '企业应用安装(公司维度)'
@@ -880,16 +880,21 @@ class UserApp(QModel):
class Meta:
db_table = 'UserApp'
app_label = 'gerp'
- unique_together = (('user_uuid', 'app_uuid'),)
+ unique_together = (('UserUUID', 'AppUUID'),)
verbose_name = '个人应用'
verbose_name_plural = '个人应用'
class AppConfig(QModel):
- """应用配置键值表——按租户隔离的应用运行参数
+ """应用配置键值表——按租户+公司隔离的应用运行参数
- 每个应用可声明若干配置键(ConfigKey),租户管理员/公司管理员
- (有安装权限者)可在"配置"入口编辑本租户下的配置值。
+ 每个应用可声明若干配置键(ConfigKey),管理员(有安装权限者)可在
+ "配置"入口编辑配置值。
+
+ 配置粒度:
+ - CompanyUUID 为空(NULL)→ 租户级默认配置(全租户共享)
+ - CompanyUUID 有值 → 公司级配置(覆盖租户默认)
+ 读取回退链:公司级 → 租户默认 → 模板默认值。
键值约定(由应用自行定义与读取,如 [stalwart] 域名映射):
ConfigKey 配置键名(如 SMTP_HOST / DOMAIN)
@@ -898,6 +903,7 @@ class AppConfig(QModel):
AppConfigUUID = UUIDField(primary_key=True, verbose_name='应用配置UUID')
TenantUUID = UUIDField(db_column='TenantUUID', verbose_name='租户UUID')
+ CompanyUUID = UUIDField(null=True, blank=True, db_column='CompanyUUID', verbose_name='公司UUID(空=租户级默认配置)')
AppUUID = UUIDField(db_column='AppUUID', verbose_name='应用UUID')
ConfigKey = models.CharField(max_length=128, verbose_name='配置键名')
ConfigValue = models.TextField(default='', verbose_name='配置值')
@@ -908,7 +914,7 @@ class AppConfig(QModel):
class Meta:
db_table = 'AppConfig'
app_label = 'gerp'
- unique_together = (('tenant_uuid', 'app_uuid', 'config_key'),)
+ unique_together = (('TenantUUID', 'CompanyUUID', 'AppUUID', 'ConfigKey'),)
verbose_name = '应用配置'
verbose_name_plural = '应用配置'
diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py
index a8230a25..0276a7ee 100644
--- a/gvsds/apps/gerp/views/__init__.py
+++ b/gvsds/apps/gerp/views/__init__.py
@@ -156,6 +156,7 @@ from .app import (
AppApproveView,
AppPendingView,
AppConfigView,
+ AppConfigCopyView,
)
from .system_ops import (
GERPSystemStatusView,
diff --git a/gvsds/apps/gerp/views/app.py b/gvsds/apps/gerp/views/app.py
index 91571b14..18128434 100644
--- a/gvsds/apps/gerp/views/app.py
+++ b/gvsds/apps/gerp/views/app.py
@@ -104,6 +104,34 @@ def _ToBytes(value):
return None
+def _ResolveConfigCompany(request, company_uuid_str):
+ """解析应用配置目标范围并校验权限(租户默认/公司级)
+
+ 规则:
+ - 不传公司 UUID:租户管理员(scope all/tenant)→ 租户默认;公司管理员 → 默认本公司
+ - 传公司 UUID :租户管理员可任意;公司管理员仅授权公司
+
+ Returns:
+ (company_bytes_or_None, err);err 非空表示拒绝
+ """
+ manageable, scope = AppManager.GetManageableCompanies(request)
+ if not company_uuid_str:
+ if scope in ('all', 'tenant'):
+ return None, None
+ if manageable:
+ return manageable[0], None
+ return None, '您没有可管理的公司'
+ target = _ParseUUIDList([company_uuid_str])
+ if not target:
+ return None, '无效的公司 UUID'
+ if scope in ('all', 'tenant'):
+ return target[0], None
+ manageable_set = {c.hex() for c in manageable}
+ if target[0].hex() in manageable_set:
+ return target[0], None
+ return None, '无权管理该公司的配置'
+
+
class AppMarketView(ELTAuthenticatedAPIView):
"""应用市场——全部启用应用,附租户/公司安装/个人启用/申请状态。"""
@@ -363,10 +391,15 @@ class AppPendingView(ELTAuthenticatedAPIView):
class AppConfigView(ELTAuthenticatedAPIView):
- """应用配置(AppConfig 键值表)——有安装权限者(管理员)可读写
+ """应用配置(租户默认/公司级)
- GET /API/App/Config?AppUUID=xxx — 返回该应用配置模板 + 当前租户已保存值
- POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}}
+ GET /API/App/Config?AppUUID=xxx[&CompanyUUID=yyy]
+ - 返回该应用配置模板 + 当前范围已保存值(公司缺失键回退租户默认)
+ - 不传 CompanyUUID = 租户默认配置(全租户共享)
+ - 租户管理员可传任意公司;公司管理员只能读本公司(不传时默认本公司)
+ POST /API/App/Config — Body: {AppUUID, Config: {Key: Value}, CompanyUUID?}
+ - 保存到指定范围(CompanyUUID 空=租户默认)
+ - 租户管理员可写任意范围;公司管理员只能写本公司
"""
permission_classes = [IsAuthenticated, IsAdminUser]
@@ -375,9 +408,18 @@ class AppConfigView(ELTAuthenticatedAPIView):
app_uuid = (request.query_params.get('AppUUID') or '').strip()
if not app_uuid:
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
+ company_str = (request.query_params.get('CompanyUUID') or '').strip()
+ company_bytes, err = _ResolveConfigCompany(request, company_str)
+ if err:
+ return Response({'code': 403, 'msg': err}, status=403)
tenant_bytes = _GetTenantBytes(request)
- rows = AppManager.GetAppConfig(tenant_bytes, app_uuid)
- return Response({'code': 0, 'data': {'Configs': rows, 'Total': len(rows)}})
+ rows = AppManager.GetAppConfig(tenant_bytes, app_uuid, company_bytes)
+ return Response({'code': 0, 'data': {
+ 'Configs': rows,
+ 'Total': len(rows),
+ 'Scope': 'company' if company_bytes else 'tenant',
+ 'CompanyUUID': UUIDObj(company_bytes).str if company_bytes else '',
+ }})
def post(self, request):
app_uuid = (request.data.get('AppUUID') or '').strip()
@@ -386,12 +428,66 @@ class AppConfigView(ELTAuthenticatedAPIView):
return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
if not isinstance(config, dict) or not config:
return Response({'code': 1, 'msg': '缺少配置项 Config'}, status=400)
+ company_str = (request.data.get('CompanyUUID') or '').strip()
+ company_bytes, err = _ResolveConfigCompany(request, company_str)
+ if err:
+ return Response({'code': 403, 'msg': err}, status=403)
tenant_bytes = _GetTenantBytes(request)
if not tenant_bytes:
return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400)
ok, err, updated = AppManager.SaveAppConfig(
- tenant_bytes, app_uuid, config, OperatorUUID=_GetUserBytes(request),
+ tenant_bytes, app_uuid, config,
+ CompanyUUID=company_bytes,
+ OperatorUUID=_GetUserBytes(request),
)
if not ok:
return Response({'code': 1, 'msg': err}, status=400)
return Response({'code': 0, 'msg': f'已保存 {len(updated)} 项配置', 'data': {'Updated': updated}})
+
+
+class AppConfigCopyView(ELTAuthenticatedAPIView):
+ """配置批量复制——把源配置(租户默认/某公司)复制到目标公司(覆盖同名键)
+
+ POST /API/App/Config/Copy — Body: {AppUUID, SourceCompanyUUID?, TargetCompanyUUIDs[]}
+ - SourceCompanyUUID 不传 = 以租户默认配置为源
+ - 权限:租户管理员任意;公司管理员仅可复制到本公司且源必须是本公司/租户默认
+ """
+
+ permission_classes = [IsAuthenticated, IsAdminUser]
+
+ def post(self, request):
+ app_uuid = (request.data.get('AppUUID') or '').strip()
+ if not app_uuid:
+ return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400)
+ targets = _ParseUUIDList(request.data.get('TargetCompanyUUIDs'))
+ if not targets:
+ return Response({'code': 1, 'msg': '未指定目标公司 TargetCompanyUUIDs'}, status=400)
+ tenant_bytes = _GetTenantBytes(request)
+ if not tenant_bytes:
+ return Response({'code': 1, 'msg': '无法确定当前租户'}, status=400)
+
+ # 权限校验:租户管理员(scope all/tenant)任意;公司管理员(scope company)仅本公司
+ manageable, scope = AppManager.GetManageableCompanies(request)
+ if scope not in ('all', 'tenant'):
+ manageable_set = {c.hex() for c in manageable}
+ invalid = [t for t in targets if t.hex() not in manageable_set]
+ if invalid:
+ return Response({'code': 403, 'msg': '无权复制到以下公司(不在您的管理范围)'}, status=403)
+
+ source_bytes = None
+ source_str = (request.data.get('SourceCompanyUUID') or '').strip()
+ if source_str:
+ src = _ParseUUIDList([source_str])
+ if not src:
+ return Response({'code': 1, 'msg': '无效的源公司 UUID'}, status=400)
+ source_bytes = src[0]
+ if scope not in ('all', 'tenant') and source_bytes.hex() not in manageable_set:
+ return Response({'code': 403, 'msg': '无权使用该公司的配置作为源'}, status=403)
+
+ ok, err, copied = AppManager.CopyAppConfig(
+ tenant_bytes, app_uuid, source_bytes, targets,
+ OperatorUUID=_GetUserBytes(request),
+ )
+ if not ok:
+ return Response({'code': 1, 'msg': err}, status=400)
+ return Response({'code': 0, 'msg': f'已复制 {copied} 项配置到 {len(targets)} 家公司', 'data': {'Copied': copied}})
diff --git a/gvsds/urls.py b/gvsds/urls.py
index 31229a95..a3228544 100644
--- a/gvsds/urls.py
+++ b/gvsds/urls.py
@@ -158,6 +158,7 @@ urlpatterns = [
path('API/App/Approve', gerp_views.AppApproveView.as_view(), name='app_approve'),
path('API/App/Pending', gerp_views.AppPendingView.as_view(), name='app_pending'),
path('API/App/Config', gerp_views.AppConfigView.as_view(), name='app_config'),
+ path('API/App/Config/Copy', gerp_views.AppConfigCopyView.as_view(), name='app_config_copy'),
# 角色权限分配(权限树勾选)
path('API/Role/PermissionsTree', gerp_views.RolePermissionsTreeView.as_view(), name='role_permissions_tree'),