From 66018e37c4da5ca1976bc6df6d7a0c484e2cd89b Mon Sep 17 00:00:00 2001 From: Johngreen Date: Thu, 12 Feb 2026 14:30:18 +0900 Subject: [PATCH] feat: filter equipment import by tenant-company mapping - Add digital_twin_company_id column to tenants table - Map spifox tenant to its digital-twin companyId - Pass companyId filter when fetching from digital-twin API - Return 404 with clear message for unmapped tenants - Improve API error messages in frontend (show server detail) --- ...n6o7_add_tenant_digital_twin_company_id.py | 35 +++++++++++++++++++ dashboard/app/[tenant]/page.tsx | 9 +++-- dashboard/lib/api.ts | 20 ++++++++--- src/api/equipment_sync.py | 17 ++++++++- src/database/models.py | 1 + src/services/equipment_sync.py | 21 +++++++++-- 6 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/j2k3l4m5n6o7_add_tenant_digital_twin_company_id.py diff --git a/alembic/versions/j2k3l4m5n6o7_add_tenant_digital_twin_company_id.py b/alembic/versions/j2k3l4m5n6o7_add_tenant_digital_twin_company_id.py new file mode 100644 index 0000000..cb9e1b8 --- /dev/null +++ b/alembic/versions/j2k3l4m5n6o7_add_tenant_digital_twin_company_id.py @@ -0,0 +1,35 @@ +"""add tenant digital_twin_company_id + +Revision ID: j2k3l4m5n6o7 +Revises: i1j2k3l4m5n6 +Create Date: 2026-02-12 11:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "j2k3l4m5n6o7" +down_revision: Union[str, None] = "i1j2k3l4m5n6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tenants", + sa.Column("digital_twin_company_id", sa.String(100), nullable=True), + ) + # Seed: map spifox to the known digital-twin companyId + op.execute( + "UPDATE tenants SET digital_twin_company_id = '7f5c058c-ef65-45e3-838e-cebaec2d6170' " + "WHERE id = 'spifox'" + ) + + +def downgrade() -> None: + op.drop_column("tenants", "digital_twin_company_id") diff --git a/dashboard/app/[tenant]/page.tsx b/dashboard/app/[tenant]/page.tsx index 5085812..cab3d96 100644 --- a/dashboard/app/[tenant]/page.tsx +++ b/dashboard/app/[tenant]/page.tsx @@ -139,7 +139,8 @@ export default function TenantDashboard() { setImportPreview(resp.equipment); setSelectedImports(new Set()); } catch (err) { - addToast('디지털 트윈 데이터를 불러오는데 실패했습니다.', 'error'); + const msg = err instanceof Error ? err.message : '디지털 트윈 데이터를 불러오는데 실패했습니다.'; + addToast(msg, 'error'); setShowImportModal(false); } finally { setImportLoading(false); @@ -216,7 +217,8 @@ export default function TenantDashboard() { mutate(); closeImportModal(); } catch (err) { - addToast('설비 가져오기에 실패했습니다.', 'error'); + const msg = err instanceof Error ? err.message : '설비 가져오기에 실패했습니다.'; + addToast(msg, 'error'); } finally { setImporting(false); } @@ -229,7 +231,8 @@ export default function TenantDashboard() { addToast(`동기화 완료: ${result.pull.synced_count}개 업데이트, ${result.push_count}개 전송`, 'success'); mutate(); } catch (err) { - addToast('동기화에 실패했습니다.', 'error'); + const msg = err instanceof Error ? err.message : '동기화에 실패했습니다.'; + addToast(msg, 'error'); } finally { setSyncing(false); } diff --git a/dashboard/lib/api.ts b/dashboard/lib/api.ts index dfafdf9..6782c9b 100644 --- a/dashboard/lib/api.ts +++ b/dashboard/lib/api.ts @@ -11,12 +11,22 @@ function getHeaders(): HeadersInit { }; } +async function parseErrorDetail(res: Response): Promise { + try { + const body = await res.json(); + return body.detail || body.message || 'API request failed'; + } catch { + return 'API request failed'; + } +} + export async function fetcher(url: string): Promise { const res = await fetch(`${API_BASE_URL}${url}`, { headers: getHeaders(), }); if (!res.ok) { - throw new Error('API request failed'); + const detail = await parseErrorDetail(res); + throw new Error(detail); } return res.json(); } @@ -38,7 +48,7 @@ export const api = { headers: getHeaders(), body: JSON.stringify(data), }); - if (!res.ok) throw new Error('API request failed'); + if (!res.ok) throw new Error(await parseErrorDetail(res)); return res.json(); }, @@ -48,7 +58,7 @@ export const api = { headers: getHeaders(), body: JSON.stringify(data), }); - if (!res.ok) throw new Error('API request failed'); + if (!res.ok) throw new Error(await parseErrorDetail(res)); return res.json(); }, @@ -58,7 +68,7 @@ export const api = { headers: getHeaders(), body: JSON.stringify(data), }); - if (!res.ok) throw new Error('API request failed'); + if (!res.ok) throw new Error(await parseErrorDetail(res)); return res.json(); }, @@ -67,7 +77,7 @@ export const api = { method: 'DELETE', headers: getHeaders(), }); - if (!res.ok) throw new Error('API request failed'); + if (!res.ok) throw new Error(await parseErrorDetail(res)); return res.json(); }, }; diff --git a/src/api/equipment_sync.py b/src/api/equipment_sync.py index 3b16340..cb7c6ba 100644 --- a/src/api/equipment_sync.py +++ b/src/api/equipment_sync.py @@ -7,7 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.database.config import get_db -from src.database.models import Machine +from src.database.models import Machine, Tenant from src.auth.models import TokenData from src.auth.dependencies import require_auth, verify_tenant_access from src.services.equipment_sync import EquipmentSyncService @@ -37,6 +37,18 @@ def _check_sync_configured(): ) +async def _check_company_mapped(db: AsyncSession, tenant_id: str): + result = await db.execute( + select(Tenant.digital_twin_company_id).where(Tenant.id == tenant_id) + ) + company_id = result.scalar_one_or_none() + if not company_id: + raise HTTPException( + status_code=404, + detail="이 업체는 디지털 트윈 회사 매핑이 설정되지 않았습니다. 관리자에게 문의하세요.", + ) + + @router.post("/sync") async def sync_machines( tenant_id: str = Path(...), @@ -45,6 +57,7 @@ async def sync_machines( ): verify_tenant_access(tenant_id, current_user) _check_sync_configured() + await _check_company_mapped(db, tenant_id) svc = EquipmentSyncService(db, tenant_id) result = await svc.sync() @@ -60,6 +73,7 @@ async def import_machines( ): verify_tenant_access(tenant_id, current_user) _check_sync_configured() + await _check_company_mapped(db, tenant_id) svc = EquipmentSyncService(db, tenant_id) result = await svc.import_equipment(body.external_ids) @@ -74,6 +88,7 @@ async def import_preview( ): verify_tenant_access(tenant_id, current_user) _check_sync_configured() + await _check_company_mapped(db, tenant_id) svc = EquipmentSyncService(db, tenant_id) remote_list = await svc.fetch_remote_equipment() diff --git a/src/database/models.py b/src/database/models.py index c66a4c2..ab572e4 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -48,6 +48,7 @@ class Tenant(Base): is_active = Column(Boolean, default=True) enabled_modules = Column(JSONB, nullable=True) workflow_config = Column(JSONB, nullable=True) + digital_twin_company_id = Column(String(100), nullable=True) created_at = Column(TIMESTAMP(timezone=True), default=utcnow) users = relationship("User", back_populates="tenant") diff --git a/src/services/equipment_sync.py b/src/services/equipment_sync.py index b2e276e..6f7d257 100644 --- a/src/services/equipment_sync.py +++ b/src/services/equipment_sync.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.database.models import Machine, MachineChangeHistory +from src.database.models import Machine, MachineChangeHistory, Tenant logger = logging.getLogger(__name__) @@ -101,10 +101,23 @@ class EquipmentSyncService: return inner.get("pagination") return None + async def _get_company_id(self) -> Optional[str]: + result = await self.db.execute( + select(Tenant.digital_twin_company_id).where(Tenant.id == self.tenant_id) + ) + return result.scalar_one_or_none() + async def fetch_remote_equipment(self) -> list[dict]: if not self.api_url: return [] try: + company_id = await self._get_company_id() + if not company_id: + logger.warning( + f"Tenant {self.tenant_id} has no digital_twin_company_id mapped" + ) + return [] + all_equipment: list[dict] = [] page = 1 max_limit = 500 @@ -113,7 +126,11 @@ class EquipmentSyncService: while True: resp = await client.get( "/api/v1/aas/equipment", - params={"page": page, "limit": max_limit}, + params={ + "page": page, + "limit": max_limit, + "companyId": company_id, + }, ) resp.raise_for_status() data = resp.json()