feat: filter equipment import by tenant-company mapping
All checks were successful
Deploy to Production / deploy (push) Successful in 2m7s

- 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)
This commit is contained in:
Johngreen
2026-02-12 14:30:18 +09:00
parent a8be53c88e
commit 66018e37c4
6 changed files with 92 additions and 11 deletions

View File

@@ -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")

View File

@@ -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);
}

View File

@@ -11,12 +11,22 @@ function getHeaders(): HeadersInit {
};
}
async function parseErrorDetail(res: Response): Promise<string> {
try {
const body = await res.json();
return body.detail || body.message || 'API request failed';
} catch {
return 'API request failed';
}
}
export async function fetcher<T>(url: string): Promise<T> {
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();
},
};

View File

@@ -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()

View File

@@ -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")

View File

@@ -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()