feat: filter equipment import by tenant-company mapping
All checks were successful
Deploy to Production / deploy (push) Successful in 2m7s
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:
@@ -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")
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user