From 68feaa10a9fc2476052023230ce2d84efcd7f6e7 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 2 Oct 2026 21:36:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(gerp):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=BC=8F=E8=BD=AF=E4=BB=B6=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E3=80=8D=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 订阅组 = 一个产品,为产品指定一家或多家提供者公司/消费者公司;可选公司范围 = 当前管理员可管理的全部公司(跨租户管理员可选任意租户)。 - 新增 subscription 子应用:models/manager/views/urls/manifest - SubscriptionGroup / SubscriptionGroupCompany(PartyType 1提供者/0消费者) - 校验:产品名必填且同租户唯一、提供者/消费者各至少一家、同一产品内不可双重身份、越权公司拒绝 - 新增迁移 0050_subscription - urls 挂载 API/Subscription/;app_manifests 登记父菜单「订阅式软件提供」 - 修复 CompanyRows 中 UUIDObj/bytes 键不匹配导致公司名为空 - 修复 SaveGroup 的 AllowedCompanyUUIDs 未归一(UUIDObj 无 hex) --- gvsds/apps/gerp/app_manifests.py | 1 + gvsds/apps/gerp/apps/subscription/__init__.py | 9 + .../apps/subscription/apps.gerp_manifests | 29 ++ gvsds/apps/gerp/apps/subscription/manager.py | 334 ++++++++++++++++++ gvsds/apps/gerp/apps/subscription/models.py | 76 ++++ gvsds/apps/gerp/apps/subscription/urls.py | 16 + gvsds/apps/gerp/apps/subscription/views.py | 141 ++++++++ .../apps/gerp/migrations/0050_subscription.py | 56 +++ gvsds/urls.py | 3 + 9 files changed, 665 insertions(+) create mode 100644 gvsds/apps/gerp/apps/subscription/__init__.py create mode 100644 gvsds/apps/gerp/apps/subscription/apps.gerp_manifests create mode 100644 gvsds/apps/gerp/apps/subscription/manager.py create mode 100644 gvsds/apps/gerp/apps/subscription/models.py create mode 100644 gvsds/apps/gerp/apps/subscription/urls.py create mode 100644 gvsds/apps/gerp/apps/subscription/views.py create mode 100644 gvsds/apps/gerp/migrations/0050_subscription.py diff --git a/gvsds/apps/gerp/app_manifests.py b/gvsds/apps/gerp/app_manifests.py index f0c85d29..3eb03a49 100644 --- a/gvsds/apps/gerp/app_manifests.py +++ b/gvsds/apps/gerp/app_manifests.py @@ -355,6 +355,7 @@ PARENT_MENU_TITLES = { 'product-order': ('产品与订单', 'Goods', 3), 'ehrm': ('人力资源管理扩展', 'UserFilled', 4), 'oa': ('办公自动化', 'Files', 5), + 'subscription': ('订阅式软件提供', 'Connection', 12), } diff --git a/gvsds/apps/gerp/apps/subscription/__init__.py b/gvsds/apps/gerp/apps/subscription/__init__.py new file mode 100644 index 00000000..dce350f0 --- /dev/null +++ b/gvsds/apps/gerp/apps/subscription/__init__.py @@ -0,0 +1,9 @@ +"""订阅式软件提供(subscription)子应用 + +一个 GERP 应用 = 本目录。目录内固定结构见上级包 `gvsds/apps/gerp/apps/__init__.py` 的说明: + models.py 该应用的模型(app_label 仍为 'gerp',迁移集中在 gerp/migrations/) + manager.py 该应用的数据访问层 + views.py 该应用的视图 + urls.py 该应用的路由(在 gvsds/urls.py 内 include) + apps.gerp_manifests 应用清单声明(由 app_manifests.py 扫描加载) +""" diff --git a/gvsds/apps/gerp/apps/subscription/apps.gerp_manifests b/gvsds/apps/gerp/apps/subscription/apps.gerp_manifests new file mode 100644 index 00000000..2fb2cefd --- /dev/null +++ b/gvsds/apps/gerp/apps/subscription/apps.gerp_manifests @@ -0,0 +1,29 @@ +{ + "$schema": "../gerp_manifests.schema.json", + "AppCode": "subscription", + "AppName": "订阅式软件提供", + "AppIcon": "Connection", + "AppDesc": "以「订阅组(产品)」为单位登记软件提供关系:为每个产品指定提供者公司与消费者公司,并汇总订阅关系", + "AppType": "enterprise", + "AppSort": 13, + "FrontendRoute": "/subscription/group", + "FrontendType": "vue", + "Menus": [ + { + "MenuName": "subscription-group", + "MenuTitle": "订阅组管理", + "MenuLink": "/subscription/group", + "MenuIcon": "Connection", + "MenuSort": 1, + "ParentMenuName": "subscription" + }, + { + "MenuName": "subscription-relation", + "MenuTitle": "订阅关系", + "MenuLink": "/subscription/relation", + "MenuIcon": "Share", + "MenuSort": 2, + "ParentMenuName": "subscription" + } + ] +} diff --git a/gvsds/apps/gerp/apps/subscription/manager.py b/gvsds/apps/gerp/apps/subscription/manager.py new file mode 100644 index 00000000..a9651eea --- /dev/null +++ b/gvsds/apps/gerp/apps/subscription/manager.py @@ -0,0 +1,334 @@ +"""模块:订阅式软件提供数据访问层 + +视图层不直接操作 ORM;所有方法遵循帕斯卡命名法。 + +术语: + 订阅组(SubscriptionGroup)= 一个「产品」; + 组内公司分两类(SubscriptionGroupCompany.PartyType): + 1 = 提供者 / 0 = 消费者 +""" + +import datetime +import uuid + +from django.db import transaction + +from gvsdsdk.model_utils import UUIDObj + +from .models import PARTY_CONSUMER, PARTY_PROVIDER, SubscriptionGroup, SubscriptionGroupCompany + + +def _ToBytes(value): + """UUIDObj / bytes / uuid.UUID / str → BINARY(16) bytes;无法转换返回 None""" + if value is None: + return None + if isinstance(value, bytes): + return value + if hasattr(value, 'bytes'): + try: + return value.bytes + except Exception: + return None + try: + return uuid.UUID(str(value)).bytes + except (ValueError, AttributeError, TypeError): + return None + + +def _ToStr(value): + if value is None: + return '' + if isinstance(value, bytes): + try: + return str(UUIDObj(value)) + except Exception: + return '' + try: + return str(UUIDObj(value)) + except Exception: + return str(value) + + +class SubscriptionManager: + """订阅组(产品)与组成员公司""" + + # ==================== 公司 / 租户展示信息 ==================== + + @staticmethod + def CompanyRows(CompanyUUIDs): + """把公司 UUID 列表(bytes)补全为展示行(含公司名与所属租户名) + + 返回顺序与入参一致(查不到的公司跳过)。 + """ + from gvsds.apps.gerp.models import Company, Tenant + + # 入参可能是 UUIDObj / bytes / str,统一归一成 BINARY(16) bytes 后再查, + # 否则 by_uuid(bytes 键)与循环变量(UUIDObj)不匹配,会全部查不到。 + wanted = [_ToBytes(c) for c in (CompanyUUIDs or [])] + wanted = [c for c in wanted if c] + if not wanted: + return [] + companies = list(Company.objects.filter(CompanyUUID__in=wanted)) + by_uuid = {_ToBytes(c.CompanyUUID): c for c in companies} + tenant_ids = [c.TenantUUID for c in companies if c.TenantUUID] + tenant_names = { + _ToStr(t.TenantUUID): t.TenantName + for t in Tenant.objects.filter(TenantUUID__in=[t for t in tenant_ids if t]) + } + rows = [] + for company_bytes in wanted: + company = by_uuid.get(company_bytes) + if company is None: + continue + tenant_str = _ToStr(company.TenantUUID) + rows.append({ + 'CompanyUUID': _ToStr(company.CompanyUUID), + 'CompanyName': company.CompanyName, + 'CompanyShort': company.CompanyShort or '', + 'TenantUUID': tenant_str, + 'TenantName': tenant_names.get(tenant_str, ''), + }) + return rows + + @staticmethod + def _MemberRows(GroupUUIDs): + """{组UUID(str): {'Providers': [...], 'Consumers': [...]}} 公司展示行""" + rows = list(SubscriptionGroupCompany.objects.filter( + SubscriptionGroupUUID__in=[_ToBytes(g) for g in GroupUUIDs])) + if not rows: + return {} + info = { + row['CompanyUUID']: row + for row in SubscriptionManager.CompanyRows([r.CompanyUUID for r in rows]) + } + result = {} + for r in rows: + key = _ToStr(r.SubscriptionGroupUUID) + bucket = result.setdefault(key, {'Providers': [], 'Consumers': []}) + info_row = info.get(_ToStr(r.CompanyUUID)) + if info_row is None: + info_row = { + 'CompanyUUID': _ToStr(r.CompanyUUID), + 'CompanyName': '', + 'CompanyShort': '', + 'TenantUUID': _ToStr(r.TenantUUID), + 'TenantName': '', + } + target = 'Providers' if r.PartyType == PARTY_PROVIDER else 'Consumers' + bucket[target].append(info_row) + return result + + @staticmethod + def _GroupRow(group, members): + bucket = members.get(_ToStr(group.SubscriptionGroupUUID), {'Providers': [], 'Consumers': []}) + return { + 'SubscriptionGroupUUID': _ToStr(group.SubscriptionGroupUUID), + 'TenantUUID': _ToStr(group.TenantUUID), + 'GroupName': group.GroupName, + 'GroupDesc': group.GroupDesc or '', + 'GroupStatus': group.GroupStatus, + 'CreateTime': group.CreateTime.isoformat() if group.CreateTime else None, + 'Providers': bucket['Providers'], + 'Consumers': bucket['Consumers'], + } + + # ==================== 订阅组(产品) ==================== + + @staticmethod + def ListGroups(TenantUUID=None): + """列出订阅组(含提供者/消费者公司名单) + + TenantUUID 为 None → 全部(跨租户管理员视角);否则仅该租户创建的组。 + """ + qs = SubscriptionGroup.objects.all() + if TenantUUID is not None: + qs = qs.filter(TenantUUID=_ToBytes(TenantUUID)) + groups = list(qs) + if not groups: + return [] + members = SubscriptionManager._MemberRows([g.SubscriptionGroupUUID for g in groups]) + return [SubscriptionManager._GroupRow(g, members) for g in groups] + + @staticmethod + def GetGroup(GroupUUID, TenantUUID=None): + """取单个组(含成员名单);越范围返回 None""" + group_bytes = _ToBytes(GroupUUID) + if group_bytes is None: + return None + qs = SubscriptionGroup.objects.filter(SubscriptionGroupUUID=group_bytes) + if TenantUUID is not None: + qs = qs.filter(TenantUUID=_ToBytes(TenantUUID)) + group = qs.first() + if group is None: + return None + members = SubscriptionManager._MemberRows([group.SubscriptionGroupUUID]) + return SubscriptionManager._GroupRow(group, members) + + @staticmethod + def SaveGroup(TenantUUID, Data, OperatorUUID=None, AllowedCompanyUUIDs=None): + """新建/更新一个订阅组,并整体重写其提供者/消费者公司名单 + + Args: + TenantUUID: 归属租户(bytes 或 str);新建时写入 + Data: {SubscriptionGroupUUID?, GroupName, GroupDesc?, GroupStatus?, Providers:[], Consumers:[]} + OperatorUUID: 创建人(仅新建时写) + AllowedCompanyUUIDs: 允许选用的公司 UUID 集合(bytes);None=不校验(调用方须自行保证) + + Returns: + (group_row, error) —— 校验失败时 group_row 为 None + """ + group_name = str(Data.get('GroupName') or '').strip() + if not group_name: + return None, '请填写产品名称' + if len(group_name) > 128: + return None, '产品名称过长(最多 128 字)' + + provider_bytes = [_ToBytes(x) for x in (Data.get('Providers') or [])] + consumer_bytes = [_ToBytes(x) for x in (Data.get('Consumers') or [])] + provider_bytes = [c for c in provider_bytes if c] + consumer_bytes = [c for c in consumer_bytes if c] + if not provider_bytes: + return None, '请至少指定一家提供者公司' + if not consumer_bytes: + return None, '请至少指定一家消费者公司' + if len(set(provider_bytes)) != len(provider_bytes) or len(set(consumer_bytes)) != len(consumer_bytes): + return None, '同一家公司在同一身份下只能出现一次' + + if provider_bytes and consumer_bytes: + overlap = {c.hex() for c in provider_bytes} & {c.hex() for c in consumer_bytes} + if overlap: + return None, '同一家公司不能在同一产品中既是提供者又是消费者' + + if AllowedCompanyUUIDs is not None: + # 允许集合同样可能是 UUIDObj(AppManager.GetManageableCompanies 返回),先归一 + allowed_bytes = [_ToBytes(c) for c in AllowedCompanyUUIDs] + allowed = {c.hex() for c in allowed_bytes if c} + outside = [c for c in (provider_bytes + consumer_bytes) if c.hex() not in allowed] + if outside: + return None, '存在不在您管理范围内的公司,请重新选择' + + group_bytes = _ToBytes(Data.get('SubscriptionGroupUUID')) + existing = None + if group_bytes is not None: + qs = SubscriptionGroup.objects.filter(SubscriptionGroupUUID=group_bytes) + if TenantUUID is not None: + qs = qs.filter(TenantUUID=_ToBytes(TenantUUID)) + existing = qs.first() + if existing is None: + return None, '订阅组不存在或不在您的管理范围' + + # 同租户内产品名唯一 + dup = SubscriptionGroup.objects.filter( + TenantUUID=_ToBytes(TenantUUID), GroupName=group_name) + if existing is not None: + dup = dup.exclude(SubscriptionGroupUUID=existing.SubscriptionGroupUUID) + if dup.exists(): + return None, f'已存在同名产品「{group_name}」' + + status = Data.get('GroupStatus') + status = 1 if status is None else (1 if int(status) else 0) + desc = str(Data.get('GroupDesc') or '').strip()[:512] + + with transaction.atomic(): + if existing is None: + group = SubscriptionGroup( + SubscriptionGroupUUID=uuid.uuid4().bytes, + TenantUUID=_ToBytes(TenantUUID), + GroupName=group_name, + GroupDesc=desc, + GroupStatus=status, + CreatorUUID=_ToBytes(OperatorUUID), + ) + group.save() + else: + group = existing + group.GroupName = group_name + group.GroupDesc = desc + group.GroupStatus = status + group.save(update_fields=['GroupName', 'GroupDesc', 'GroupStatus']) + + # 成员名单整体重写(组内成员数量有限,重写比 diff 更简单可靠) + SubscriptionGroupCompany.objects.filter( + SubscriptionGroupUUID=group.SubscriptionGroupUUID).delete() + now = datetime.datetime.utcnow() + for party_type, values in ((PARTY_PROVIDER, provider_bytes), (PARTY_CONSUMER, consumer_bytes)): + for company_bytes in values: + SubscriptionGroupCompany.objects.create( + SubscriptionGroupCompanyUUID=uuid.uuid4().bytes, + SubscriptionGroupUUID=group.SubscriptionGroupUUID, + CompanyUUID=company_bytes, + TenantUUID=_ToBytes(SubscriptionManager._CompanyTenant(company_bytes)), + PartyType=party_type, + CreateTime=now, + ) + + members = SubscriptionManager._MemberRows([group.SubscriptionGroupUUID]) + return SubscriptionManager._GroupRow(group, members), None + + @staticmethod + def _CompanyTenant(CompanyUUID): + """公司所属租户 UUID(查不到返回 None)""" + from gvsds.apps.gerp.models import Company + company = Company.objects.filter(CompanyUUID=CompanyUUID).first() + return company.TenantUUID if company else None + + @staticmethod + def DeleteGroup(GroupUUID, TenantUUID=None): + """删除订阅组及其成员公司名单 + + Returns: + (成功?, 错误消息) + """ + group_bytes = _ToBytes(GroupUUID) + if group_bytes is None: + return False, '无效的订阅组 UUID' + qs = SubscriptionGroup.objects.filter(SubscriptionGroupUUID=group_bytes) + if TenantUUID is not None: + qs = qs.filter(TenantUUID=_ToBytes(TenantUUID)) + group = qs.first() + if group is None: + return False, '订阅组不存在或不在您的管理范围' + with transaction.atomic(): + SubscriptionGroupCompany.objects.filter( + SubscriptionGroupUUID=group.SubscriptionGroupUUID).delete() + group.delete() + return True, '' + + # ==================== 订阅关系(按公司汇总) ==================== + + @staticmethod + def ListRelations(TenantUUID=None): + """订阅关系展示:按公司汇总它作为「提供者 / 消费者」参与的产品 + + TenantUUID 为 None → 全部;否则只统计该租户创建的组。 + """ + rows = SubscriptionManager.ListGroups(TenantUUID=TenantUUID) + index = {} + + def _bucket(company): + key = company.get('CompanyUUID') or '' + entry = index.setdefault(key, { + 'CompanyUUID': company.get('CompanyUUID') or '', + 'CompanyName': company.get('CompanyName') or '', + 'CompanyShort': company.get('CompanyShort') or '', + 'TenantUUID': company.get('TenantUUID') or '', + 'TenantName': company.get('TenantName') or '', + 'ProviderGroups': [], + 'ConsumerGroups': [], + }) + return entry + + for group in rows: + for company in group.get('Providers') or []: + _bucket(company)['ProviderGroups'].append({ + 'SubscriptionGroupUUID': group['SubscriptionGroupUUID'], + 'GroupName': group['GroupName'], + 'GroupStatus': group['GroupStatus'], + }) + for company in group.get('Consumers') or []: + _bucket(company)['ConsumerGroups'].append({ + 'SubscriptionGroupUUID': group['SubscriptionGroupUUID'], + 'GroupName': group['GroupName'], + 'GroupStatus': group['GroupStatus'], + }) + return list(index.values()) diff --git a/gvsds/apps/gerp/apps/subscription/models.py b/gvsds/apps/gerp/apps/subscription/models.py new file mode 100644 index 00000000..3b2d08a4 --- /dev/null +++ b/gvsds/apps/gerp/apps/subscription/models.py @@ -0,0 +1,76 @@ +"""订阅式软件提供模型(app_label 保持 'gerp',迁移集中在 gerp/migrations/) + +术语: + 订阅组(SubscriptionGroup)= 一个「产品」; + 组内公司分两类(SubscriptionGroupCompany.PartyType): + 1 = 提供者(提供该产品/服务) + 0 = 消费者(订阅该产品/服务) + 同一家公司可以既是 A 产品的提供者,又是 B 产品的消费者。 +""" + +import datetime + +from django.db import models + +from gvsds.apps.core.models import UUIDField +from gvsds.model_base import QModel + +#: 组成员公司的一方类型 +PARTY_CONSUMER = 0 +PARTY_PROVIDER = 1 + + +class SubscriptionGroup(QModel): + """订阅组(对应一个产品)——订阅式软件提供的基本单位 + + TenantUUID 为创建时的租户上下文(用于数据隔离);跨租户管理员(admin:super) + 创建组时可选其它租户的公司作为提供者/消费者,故组成员公司不限于本租户。 + """ + + SubscriptionGroupUUID = UUIDField(primary_key=True, verbose_name='订阅组UUID') + TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', db_index=True, verbose_name='归属租户UUID') + GroupName = models.CharField(max_length=128, verbose_name='产品名称') + GroupDesc = models.CharField(max_length=512, default='', verbose_name='产品说明') + GroupStatus = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0停用)') + CreatorUUID = UUIDField(null=True, blank=True, db_column='CreatorUUID', verbose_name='创建人UUID') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'SubscriptionGroup' + app_label = 'gerp' + ordering = ['-CreateTime'] + # 同租户内产品名唯一(跨租户可同名) + unique_together = (('TenantUUID', 'GroupName'),) + verbose_name = '订阅组' + verbose_name_plural = '订阅组' + + def __str__(self): + return self.GroupName + + +class SubscriptionGroupCompany(QModel): + """订阅组成员公司——组内的提供者 / 消费者 + + PartyType:1=提供者 / 0=消费者。 + TenantUUID 冗余存该公司的所属租户:跨租户管理员可选多家租户的公司,展示时据此分组。 + """ + + SubscriptionGroupCompanyUUID = UUIDField(primary_key=True, verbose_name='成员UUID') + SubscriptionGroupUUID = UUIDField( + db_column='SubscriptionGroupUUID', db_index=True, verbose_name='订阅组UUID') + CompanyUUID = UUIDField(db_column='CompanyUUID', db_index=True, verbose_name='公司UUID') + TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='公司所属租户UUID') + PartyType = models.SmallIntegerField(default=PARTY_CONSUMER, verbose_name='一方类型(1提供者/0消费者)') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'SubscriptionGroupCompany' + app_label = 'gerp' + ordering = ['PartyType', 'CreateTime'] + # 同一组内,同一家公司同一身份只允许一条 + unique_together = (('SubscriptionGroupUUID', 'CompanyUUID', 'PartyType'),) + verbose_name = '订阅组成员公司' + verbose_name_plural = '订阅组成员公司' + + def __str__(self): + return f'{self.SubscriptionGroupUUID} → {self.CompanyUUID}' diff --git a/gvsds/apps/gerp/apps/subscription/urls.py b/gvsds/apps/gerp/apps/subscription/urls.py new file mode 100644 index 00000000..247a9798 --- /dev/null +++ b/gvsds/apps/gerp/apps/subscription/urls.py @@ -0,0 +1,16 @@ +"""订阅式软件提供(subscription)路由 + +挂载点:`gvsds/urls.py` → `path('API/Subscription/', include('gvsds.apps.gerp.apps.subscription.urls'))` +""" + +from django.urls import path + +from . import views + +urlpatterns = [ + path('Groups', views.SubscriptionGroupListView.as_view(), name='subscription_groups'), + path('Group/Save', views.SubscriptionGroupSaveView.as_view(), name='subscription_group_save'), + path('Group/Delete', views.SubscriptionGroupDeleteView.as_view(), name='subscription_group_delete'), + path('Relations', views.SubscriptionRelationView.as_view(), name='subscription_relations'), + path('Companies', views.SubscriptionCompanyView.as_view(), name='subscription_companies'), +] diff --git a/gvsds/apps/gerp/apps/subscription/views.py b/gvsds/apps/gerp/apps/subscription/views.py new file mode 100644 index 00000000..ef3ac851 --- /dev/null +++ b/gvsds/apps/gerp/apps/subscription/views.py @@ -0,0 +1,141 @@ +"""模块:订阅式软件提供视图 + +URL 路径最后一段作为 action 名称: + GET /API/Subscription/Groups - 订阅组(产品)列表(含提供者/消费者公司) + POST /API/Subscription/Group/Save - 新建/更新订阅组(整体重写公司名单) + POST /API/Subscription/Group/Delete - 删除订阅组 + GET /API/Subscription/Relations - 订阅关系(按公司汇总其提供/消费的产品) + GET /API/Subscription/Companies - 可选公司列表(当前管理员全部可管理公司,含所属租户) + +权限:管理员(admin:super / admin:tenant / admin:company)。 +公司可选范围 = AppManager.GetManageableCompanies: + admin:super → 全部租户全部公司(跨租户) + admin:tenant → 本租户全部公司 + admin:company → 被授权公司 +订阅组归属创建时的租户上下文(列表按租户隔离;admin:super 看全部)。 +""" + +import logging + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from gvsdsdk.auth.permissions import IsAdminUser +from gvsdsdk.model_utils import UUIDObj + +from gvsds.apps.gerp.app_manager import AppManager +from gvsds.apps.gerp.views import ELTAuthenticatedAPIView + +from .manager import SubscriptionManager + +logger = logging.getLogger(__name__) + + +def _ToBytes(value): + if value is None: + return None + if isinstance(value, bytes): + return value + if hasattr(value, 'bytes'): + try: + return value.bytes + except Exception: + return None + try: + return UUIDObj(str(value)).bytes + except Exception: + return None + + +def _ResolveScope(request): + """解析当前管理员的公司可选范围与订阅组归属租户 + + Returns: + (AllowedCompanyUUIDs: list[bytes], Scope: str, OwnerTenantUUID: bytes|None, AllTenants: bool) + Scope 为空字符串表示无管理员权限。 + """ + companies, scope = AppManager.GetManageableCompanies(request) + all_tenants = scope == 'all' + owner_tenant = _ToBytes(getattr(request, 'current_tenant_uuid', None)) + return companies, scope, owner_tenant, all_tenants + + +class SubscriptionGroupListView(ELTAuthenticatedAPIView): + """订阅组(产品)列表""" + + permission_classes = [IsAuthenticated, IsAdminUser] + + def get(self, request): + companies, scope, owner_tenant, all_tenants = _ResolveScope(request) + if not scope: + return Response({'code': 403, 'msg': '无权访问订阅组:您不是管理员'}, status=403) + rows = SubscriptionManager.ListGroups(TenantUUID=None if all_tenants else owner_tenant) + return Response({ + 'code': 0, + 'data': {'list': rows, 'total': len(rows), 'Scope': scope}, + }) + + +class SubscriptionGroupSaveView(ELTAuthenticatedAPIView): + """新建 / 更新订阅组(公司名单整体重写)""" + + permission_classes = [IsAuthenticated, IsAdminUser] + + def post(self, request): + companies, scope, owner_tenant, all_tenants = _ResolveScope(request) + if not scope: + return Response({'code': 403, 'msg': '无权保存订阅组:您不是管理员'}, status=403) + row, err = SubscriptionManager.SaveGroup( + TenantUUID=owner_tenant, + Data=request.data, + OperatorUUID=getattr(request.user, 'UserUUID', None), + # 跨租户管理员可选任意公司;其余限定在其可管理公司内(防越权指定) + AllowedCompanyUUIDs=None if all_tenants else companies, + ) + if err: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '保存成功', 'data': row}) + + +class SubscriptionGroupDeleteView(ELTAuthenticatedAPIView): + """删除订阅组(连带成员公司名单)""" + + permission_classes = [IsAuthenticated, IsAdminUser] + + def post(self, request): + _companies, scope, owner_tenant, all_tenants = _ResolveScope(request) + if not scope: + return Response({'code': 403, 'msg': '无权删除订阅组:您不是管理员'}, status=403) + ok, err = SubscriptionManager.DeleteGroup( + request.data.get('SubscriptionGroupUUID'), + TenantUUID=None if all_tenants else owner_tenant, + ) + if not ok: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '删除成功'}) + + +class SubscriptionRelationView(ELTAuthenticatedAPIView): + """订阅关系:按公司汇总它作为提供者 / 消费者参与的产品""" + + permission_classes = [IsAuthenticated, IsAdminUser] + + def get(self, request): + _companies, scope, owner_tenant, all_tenants = _ResolveScope(request) + if not scope: + return Response({'code': 403, 'msg': '无权查看订阅关系:您不是管理员'}, status=403) + rows = SubscriptionManager.ListRelations(TenantUUID=None if all_tenants else owner_tenant) + return Response({'code': 0, 'data': {'list': rows, 'total': len(rows), 'Scope': scope}}) + + +class SubscriptionCompanyView(ELTAuthenticatedAPIView): + """可选公司列表(含所属租户名),用于提供者/消费者选择器""" + + permission_classes = [IsAuthenticated, IsAdminUser] + + def get(self, request): + companies, scope, _owner_tenant, _all_tenants = _ResolveScope(request) + if not scope: + return Response({'code': 403, 'msg': '无权查看公司列表:您不是管理员'}, status=403) + rows = SubscriptionManager.CompanyRows(companies) + return Response({'code': 0, 'data': {'list': rows, 'total': len(rows), 'Scope': scope}}) diff --git a/gvsds/apps/gerp/migrations/0050_subscription.py b/gvsds/apps/gerp/migrations/0050_subscription.py new file mode 100644 index 00000000..80ef2330 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0050_subscription.py @@ -0,0 +1,56 @@ +"""订阅式软件提供(subscription):订阅组(产品) + 组成员公司(提供者/消费者) + +一个「订阅组」= 一个产品;组内指定一家或多家公司作为提供者、一家或多家作为消费者。 +""" +import datetime + +from django.db import migrations, models + +import gvsds.apps.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0049_employee_profile_id_card'), + ] + + operations = [ + migrations.CreateModel( + name='SubscriptionGroup', + fields=[ + ('SubscriptionGroupUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='订阅组UUID')), + ('TenantUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='TenantUUID', db_index=True, null=True, verbose_name='归属租户UUID')), + ('GroupName', models.CharField(max_length=128, verbose_name='产品名称')), + ('GroupDesc', models.CharField(default='', max_length=512, verbose_name='产品说明')), + ('GroupStatus', models.SmallIntegerField(default=1, verbose_name='状态(1启用/0停用)')), + ('CreatorUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='CreatorUUID', null=True, verbose_name='创建人UUID')), + ('CreateTime', models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')), + ], + options={ + 'db_table': 'SubscriptionGroup', + 'ordering': ['-CreateTime'], + 'unique_together': {('TenantUUID', 'GroupName')}, + 'verbose_name': '订阅组', + 'verbose_name_plural': '订阅组', + }, + ), + migrations.CreateModel( + name='SubscriptionGroupCompany', + fields=[ + ('SubscriptionGroupCompanyUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='成员UUID')), + ('SubscriptionGroupUUID', gvsds.apps.core.models.UUIDField(db_column='SubscriptionGroupUUID', db_index=True, verbose_name='订阅组UUID')), + ('CompanyUUID', gvsds.apps.core.models.UUIDField(db_column='CompanyUUID', db_index=True, verbose_name='公司UUID')), + ('TenantUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='TenantUUID', null=True, verbose_name='公司所属租户UUID')), + ('PartyType', models.SmallIntegerField(default=0, verbose_name='一方类型(1提供者/0消费者)')), + ('CreateTime', models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')), + ], + options={ + 'db_table': 'SubscriptionGroupCompany', + 'ordering': ['PartyType', 'CreateTime'], + 'unique_together': {('SubscriptionGroupUUID', 'CompanyUUID', 'PartyType')}, + 'verbose_name': '订阅组成员公司', + 'verbose_name_plural': '订阅组成员公司', + }, + ), + ] diff --git a/gvsds/urls.py b/gvsds/urls.py index 176323c7..3b00271f 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -207,6 +207,9 @@ urlpatterns = [ # 办公自动化流程(OA 审批)——路由在子应用内(apps/oa/urls.py) path('API/Oa/', include('gvsds.apps.gerp.apps.oa.urls')), + # 订阅式软件提供(订阅组=产品:提供者/消费者公司)——路由在子应用内(apps/subscription/urls.py) + path('API/Subscription/', include('gvsds.apps.gerp.apps.subscription.urls')), + # GERP系统 运维管理(仅系统管理员 admin:super) path('API/GERPSystem/Status', gerp_views.GERPSystemStatusView.as_view(), name='gerp_system_status'), path('API/GERPSystem/Schedules', gerp_views.GERPSystemSchedulesView.as_view(), name='gerp_system_schedules'),