diff --git a/gvsds/apps/gerp/apps/subscription/apps.gerp_manifests b/gvsds/apps/gerp/apps/subscription/apps.gerp_manifests index 2fb2cefd..960bad39 100644 --- a/gvsds/apps/gerp/apps/subscription/apps.gerp_manifests +++ b/gvsds/apps/gerp/apps/subscription/apps.gerp_manifests @@ -5,16 +5,25 @@ "AppIcon": "Connection", "AppDesc": "以「订阅组(产品)」为单位登记软件提供关系:为每个产品指定提供者公司与消费者公司,并汇总订阅关系", "AppType": "enterprise", + "RequirePositionBinding": 1, "AppSort": 13, - "FrontendRoute": "/subscription/group", + "FrontendRoute": "/subscription/software", "FrontendType": "vue", "Menus": [ + { + "MenuName": "subscription-software", + "MenuTitle": "软件管理", + "MenuLink": "/subscription/software", + "MenuIcon": "Management", + "MenuSort": 1, + "ParentMenuName": "subscription" + }, { "MenuName": "subscription-group", "MenuTitle": "订阅组管理", "MenuLink": "/subscription/group", "MenuIcon": "Connection", - "MenuSort": 1, + "MenuSort": 2, "ParentMenuName": "subscription" }, { @@ -22,8 +31,31 @@ "MenuTitle": "订阅关系", "MenuLink": "/subscription/relation", "MenuIcon": "Share", - "MenuSort": 2, + "MenuSort": 3, "ParentMenuName": "subscription" } + ], + "VirtualPositions": [ + { + "Code": "software-provider", + "Name": "软件提供方", + "MenuNames": ["subscription-software", "subscription-group"] + }, + { + "Code": "software-consumer", + "Name": "软件消费方", + "MenuNames": ["subscription-software"] + }, + { + "Code": "subscription-manager", + "Name": "订阅管理员", + "MenuNames": ["subscription-software", "subscription-group", "subscription-relation"] + } + ], + "PositionTemplates": [ + { + "CategoryName": "订阅", + "Positions": ["软件提供方", "软件消费方", "订阅管理员"] + } ] } diff --git a/gvsds/apps/gerp/apps/subscription/manager.py b/gvsds/apps/gerp/apps/subscription/manager.py index a9651eea..d3822388 100644 --- a/gvsds/apps/gerp/apps/subscription/manager.py +++ b/gvsds/apps/gerp/apps/subscription/manager.py @@ -15,7 +15,13 @@ from django.db import transaction from gvsdsdk.model_utils import UUIDObj -from .models import PARTY_CONSUMER, PARTY_PROVIDER, SubscriptionGroup, SubscriptionGroupCompany +from .models import ( + PARTY_CONSUMER, + PARTY_PROVIDER, + SubscriptionAppParty, + SubscriptionGroup, + SubscriptionGroupCompany, +) def _ToBytes(value): @@ -332,3 +338,82 @@ class SubscriptionManager: 'GroupStatus': group['GroupStatus'], }) return list(index.values()) + + # ==================== 应用级订阅方(应用本身的两方公司,初始化引用) ==================== + + @staticmethod + def ListAppParties(AppUUID): + """读取某应用的提供者 / 消费者公司(不分具体产品) + + Returns: + dict: {'AppUUID': str, 'Providers': [公司展示行], 'Consumers': [公司展示行]} + """ + app_bytes = _ToBytes(AppUUID) + if app_bytes is None: + return {'AppUUID': '', 'Providers': [], 'Consumers': []} + rows = list(SubscriptionAppParty.objects.filter(AppUUID=app_bytes)) + info = { + row['CompanyUUID']: row + for row in SubscriptionManager.CompanyRows([r.CompanyUUID for r in rows]) + } + result = {'AppUUID': _ToStr(app_bytes), 'Providers': [], 'Consumers': []} + for r in rows: + target = 'Providers' if r.PartyType == PARTY_PROVIDER else 'Consumers' + company = info.get(_ToStr(r.CompanyUUID)) + if company is None: + company = { + 'CompanyUUID': _ToStr(r.CompanyUUID), + 'CompanyName': '', + 'CompanyShort': '', + 'TenantUUID': _ToStr(r.TenantUUID), + 'TenantName': '', + } + result[target].append(company) + return result + + @staticmethod + def SaveAppParties(AppUUID, TenantUUID, Data, AllowedCompanyUUIDs=None): + """整体重写某应用的提供者 / 消费者公司(应用级,不分产品) + + Args: + AppUUID: 应用 UUID + TenantUUID: 归属租户(公司查不到所属租户时的兜底) + Data: {'Providers': [公司UUID...], 'Consumers': [公司UUID...]}(允许为空=清除) + AllowedCompanyUUIDs: 允许选用的公司集合(bytes);None=不校验 + + Returns: + (row, error) —— 校验失败时 row 为 None + """ + app_bytes = _ToBytes(AppUUID) + if app_bytes is None: + return None, '无效的应用 UUID' + provider_bytes = [c for c in (_ToBytes(x) for x in (Data.get('Providers') or [])) if c] + consumer_bytes = [c for c in (_ToBytes(x) for x in (Data.get('Consumers') or [])) if c] + if (len(set(provider_bytes)) != len(provider_bytes) + or len(set(consumer_bytes)) != len(consumer_bytes)): + return None, '同一家公司在同一身份下只能出现一次' + overlap = {c.hex() for c in provider_bytes} & {c.hex() for c in consumer_bytes} + if overlap: + return None, '同一家公司不能既是提供者又是消费者' + if AllowedCompanyUUIDs is not None: + allowed = {c.hex() for c in (_ToBytes(x) for x in AllowedCompanyUUIDs) if c} + outside = [c for c in (provider_bytes + consumer_bytes) if c.hex() not in allowed] + if outside: + return None, '存在不在您管理范围内的公司,请重新选择' + + tenant_bytes = _ToBytes(TenantUUID) + with transaction.atomic(): + SubscriptionAppParty.objects.filter(AppUUID=app_bytes).delete() + now = datetime.datetime.utcnow() + for party_type, values in ((PARTY_PROVIDER, provider_bytes), (PARTY_CONSUMER, consumer_bytes)): + for company_bytes in values: + SubscriptionAppParty.objects.create( + SubscriptionAppPartyUUID=uuid.uuid4().bytes, + AppUUID=app_bytes, + # 公司所属租户优先;查不到时回退到调用方租户上下文 + TenantUUID=_ToBytes(SubscriptionManager._CompanyTenant(company_bytes)) or tenant_bytes, + CompanyUUID=company_bytes, + PartyType=party_type, + CreateTime=now, + ) + return SubscriptionManager.ListAppParties(app_bytes), None diff --git a/gvsds/apps/gerp/apps/subscription/models.py b/gvsds/apps/gerp/apps/subscription/models.py index 3b2d08a4..4acb721b 100644 --- a/gvsds/apps/gerp/apps/subscription/models.py +++ b/gvsds/apps/gerp/apps/subscription/models.py @@ -74,3 +74,33 @@ class SubscriptionGroupCompany(QModel): def __str__(self): return f'{self.SubscriptionGroupUUID} → {self.CompanyUUID}' + + +class SubscriptionAppParty(QModel): + """应用级订阅方——「订阅式软件提供」用于初始化引用的提供者/消费者公司 + + 与 SubscriptionGroupCompany(产品级)不同:这里登记的是**应用本身**的两方公司, + 不分具体产品,作为订阅关系的初始化引用(应用配置页的注入卡片即读写本表)。 + + PartyType:1=提供者 / 0=消费者;同一应用同一公司同一身份只允许一条。 + TenantUUID 冗余存公司所属租户,便于按租户分组展示。 + """ + + SubscriptionAppPartyUUID = UUIDField(primary_key=True, verbose_name='应用订阅方UUID') + AppUUID = UUIDField(db_column='AppUUID', db_index=True, verbose_name='应用UUID') + TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', db_index=True, verbose_name='归属租户UUID') + CompanyUUID = UUIDField(db_column='CompanyUUID', db_index=True, 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 = 'SubscriptionAppParty' + app_label = 'gerp' + ordering = ['PartyType', 'CreateTime'] + # 同一应用内,同一家公司同一身份只允许一条 + unique_together = (('AppUUID', 'CompanyUUID', 'PartyType'),) + verbose_name = '应用订阅方' + verbose_name_plural = '应用订阅方' + + def __str__(self): + return f'{self.AppUUID} → {self.CompanyUUID}' diff --git a/gvsds/apps/gerp/apps/subscription/urls.py b/gvsds/apps/gerp/apps/subscription/urls.py index 247a9798..28aabf90 100644 --- a/gvsds/apps/gerp/apps/subscription/urls.py +++ b/gvsds/apps/gerp/apps/subscription/urls.py @@ -13,4 +13,7 @@ urlpatterns = [ 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'), + # 应用级订阅方(应用配置页注入卡片) + path('AppParties', views.SubscriptionAppPartyView.as_view(), name='subscription_app_parties'), + path('AppParty/Save', views.SubscriptionAppPartySaveView.as_view(), name='subscription_app_party_save'), ] diff --git a/gvsds/apps/gerp/apps/subscription/views.py b/gvsds/apps/gerp/apps/subscription/views.py index ef3ac851..b0113575 100644 --- a/gvsds/apps/gerp/apps/subscription/views.py +++ b/gvsds/apps/gerp/apps/subscription/views.py @@ -6,6 +6,8 @@ URL 路径最后一段作为 action 名称: POST /API/Subscription/Group/Delete - 删除订阅组 GET /API/Subscription/Relations - 订阅关系(按公司汇总其提供/消费的产品) GET /API/Subscription/Companies - 可选公司列表(当前管理员全部可管理公司,含所属租户) + GET /API/Subscription/AppParties - 应用级订阅方(应用配置页注入卡片:提供者/消费者公司) + POST /API/Subscription/AppParty/Save - 保存应用级订阅方(整体重写) 权限:管理员(admin:super / admin:tenant / admin:company)。 公司可选范围 = AppManager.GetManageableCompanies: @@ -139,3 +141,45 @@ class SubscriptionCompanyView(ELTAuthenticatedAPIView): return Response({'code': 403, 'msg': '无权查看公司列表:您不是管理员'}, status=403) rows = SubscriptionManager.CompanyRows(companies) return Response({'code': 0, 'data': {'list': rows, 'total': len(rows), 'Scope': scope}}) + + +class SubscriptionAppPartyView(ELTAuthenticatedAPIView): + """应用级订阅方(应用本身的两方公司,不分产品)——应用配置页注入卡片读取 + + GET /API/Subscription/AppParties?AppUUID=xxx + """ + + 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) + app_uuid = (request.query_params.get('AppUUID') or '').strip() + if not app_uuid: + return Response({'code': 1, 'msg': '缺少应用 UUID'}, status=400) + return Response({'code': 0, 'data': SubscriptionManager.ListAppParties(app_uuid)}) + + +class SubscriptionAppPartySaveView(ELTAuthenticatedAPIView): + """保存应用级订阅方(整体重写)——应用配置页注入卡片保存 + + POST /API/Subscription/AppParty/Save {AppUUID, Providers: [], Consumers: []} + """ + + 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.SaveAppParties( + AppUUID=request.data.get('AppUUID'), + TenantUUID=owner_tenant, + Data=request.data, + # 跨租户管理员可选任意公司;其余限定在其可管理公司内(防越权指定) + AllowedCompanyUUIDs=None if all_tenants else companies, + ) + if err: + return Response({'code': 1, 'msg': err}, status=400) + return Response({'code': 0, 'msg': '保存成功', 'data': row}) diff --git a/gvsds/apps/gerp/migrations/0051_subscription_app_party.py b/gvsds/apps/gerp/migrations/0051_subscription_app_party.py new file mode 100644 index 00000000..98cb10b5 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0051_subscription_app_party.py @@ -0,0 +1,35 @@ +"""订阅式软件提供(subscription):应用级订阅方(Application 提供者/消费者公司,用于初始化引用) +""" +import datetime + +from django.db import migrations, models + +import gvsds.apps.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0050_subscription'), + ] + + operations = [ + migrations.CreateModel( + name='SubscriptionAppParty', + fields=[ + ('SubscriptionAppPartyUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='应用订阅方UUID')), + ('AppUUID', gvsds.apps.core.models.UUIDField(db_column='AppUUID', db_index=True, verbose_name='应用UUID')), + ('TenantUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='TenantUUID', db_index=True, null=True, verbose_name='归属租户UUID')), + ('CompanyUUID', gvsds.apps.core.models.UUIDField(db_column='CompanyUUID', db_index=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': 'SubscriptionAppParty', + 'ordering': ['PartyType', 'CreateTime'], + 'unique_together': {('AppUUID', 'CompanyUUID', 'PartyType')}, + 'verbose_name': '应用订阅方', + 'verbose_name_plural': '应用订阅方', + }, + ), + ]