Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,23 @@ docker compose config --quiet
## 정리

Archive Platform Ecosystem은 Archive-Market, Archive-Nexus, Archive-Logistics, Archive-Ledger, ArchiveOS를 연결해 외부 수요, 제조 이벤트 생성, 물류 경로와 비용 계산, 금융성 원장과 정산, 승인과 정책 근거, 장애 관제를 하나의 이벤트 드리븐 AX 백엔드 흐름으로 구현한 Java/Spring 기반 프로젝트입니다. 각 서비스는 Outbox, idempotency, retry, safe-mode, DEGRADED 상태 분리를 통해 외부 장애가 전체 런타임으로 전파되지 않도록 설계했습니다.
# ArchiveOS Console V3

ArchiveOS는 Archive-Market, Archive-Nexus, Archive-Logistics, Archive-Ledger의 상태·합성 런타임 이벤트·승인·정산 균형을 읽기 전용으로 관제하는 Control Tower입니다.

## 핵심 콘솔

- **대시보드**: 전체 상태, 라이브 메쉬, 우선 조치, 서비스 균형
- **서비스**: 핵심 서비스 상태와 외부 연동(Atlas) 분리
- **운영**: 에이전트, 작업 역량, 작업 흐름, 자동화
- **재무**: 합성 정산 흐름, 서비스별 손익, 승인·정산, 대사
- **기록**: 실시간 이벤트, 감사 이력, 운영 지식
- **설정**: 일반 설정, 연동, 고급 도구

실시간 메쉬는 `GET /api/live-flow/stream` SSE를 우선 사용하며 실제로 수집·저장된 합성 런타임 이벤트만 표시합니다. 실제 고객·결제·계좌·금융 데이터는 사용하지 않습니다.

Archive 핵심 상태는 Market/Nexus/Logistics/Ledger/ArchiveOS만으로 계산합니다. Atlas와 실험 시스템은 외부 연동으로 분리되며, 기본 비활성인 Labs 시스템은 핵심 KPI나 상태 판정에 포함되지 않습니다.

## 언어 지원

한국어(기본), English, 日本語, 简体中文을 지원합니다. 우측 상단 지구본 메뉴에서 선택하며 `archive.locale`에 저장합니다. eventType, API path, enum 및 기존 계약 호환 키(`logitics`)는 번역하지 않고 UI 문구만 번역합니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.archiveos.ai.ecosystem;

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class EcosystemBalanceController {
private final EcosystemBalanceService service;
public EcosystemBalanceController(EcosystemBalanceService service) { this.service = service; }
@GetMapping("/api/ecosystem/balance/summary") public Map<String, Object> summary() { return envelope(service.summary()); }
@GetMapping("/api/ecosystem/balance/recommendations") public Map<String, Object> recommendations() { return envelope(service.recommendations()); }
@PostMapping("/api/ecosystem/balance/simulate") public Map<String, Object> simulate(@RequestBody(required = false) Map<String, Object> request) { return envelope(service.simulate(request)); }
private Map<String, Object> envelope(Object data) { Map<String, Object> result = new LinkedHashMap<>(); result.put("data", data); return result; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.archiveos.ai.ecosystem;

import java.math.BigDecimal;
import org.springframework.boot.context.properties.ConfigurationProperties;

/** Thresholds for synthetic ecosystem balance observations. */
@ConfigurationProperties(prefix = "archiveos.ecosystem.balance")
public class EcosystemBalanceProperties {
private Margin market = new Margin(8, 18);
private Margin nexus = new Margin(5, 12);
private Margin logistics = new Margin(3, 10);
private Margin ledger = new Margin(4, 12);
private Margin archiveos = new Margin(0, 8);
private int backlogWarning = 20;
private int capacityWarningPercent = 90;
private int profitConcentrationPercent = 55;

public Margin getMarket() { return market; } public void setMarket(Margin value) { market = value; }
public Margin getNexus() { return nexus; } public void setNexus(Margin value) { nexus = value; }
public Margin getLogistics() { return logistics; } public void setLogistics(Margin value) { logistics = value; }
public Margin getLedger() { return ledger; } public void setLedger(Margin value) { ledger = value; }
public Margin getArchiveos() { return archiveos; } public void setArchiveos(Margin value) { archiveos = value; }
public int getBacklogWarning() { return backlogWarning; } public void setBacklogWarning(int value) { backlogWarning = value; }
public int getCapacityWarningPercent() { return capacityWarningPercent; } public void setCapacityWarningPercent(int value) { capacityWarningPercent = value; }
public int getProfitConcentrationPercent() { return profitConcentrationPercent; } public void setProfitConcentrationPercent(int value) { profitConcentrationPercent = value; }

public Margin marginFor(String key) {
return switch (key) {
case "market" -> market;
case "nexus" -> nexus;
case "logitics", "logistics" -> logistics;
case "ledger" -> ledger;
default -> archiveos;
};
}

public static class Margin {
private BigDecimal minMargin;
private BigDecimal maxMargin;
public Margin() { this(BigDecimal.ZERO, BigDecimal.ZERO); }
public Margin(int minMargin, int maxMargin) { this(BigDecimal.valueOf(minMargin), BigDecimal.valueOf(maxMargin)); }
public Margin(BigDecimal minMargin, BigDecimal maxMargin) { this.minMargin = minMargin; this.maxMargin = maxMargin; }
public BigDecimal getMinMargin() { return minMargin; } public void setMinMargin(BigDecimal value) { minMargin = value; }
public BigDecimal getMaxMargin() { return maxMargin; } public void setMaxMargin(BigDecimal value) { maxMargin = value; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.archiveos.ai.ecosystem;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;

/** Read-only explanation of synthetic cross-service financial and capacity balance. */
@Service
public class EcosystemBalanceService {
private final EcosystemService ecosystem;
private final EcosystemBalanceProperties policy;

public EcosystemBalanceService(EcosystemService ecosystem, EcosystemBalanceProperties policy) {
this.ecosystem = ecosystem;
this.policy = policy;
}

public Map<String, Object> summary() {
Map<String, Object> services = map(ecosystem.summary().get("services"));
List<Map<String, Object>> rows = new ArrayList<>();
BigDecimal totalRevenue = BigDecimal.ZERO;
BigDecimal totalCost = BigDecimal.ZERO;
BigDecimal totalProfit = BigDecimal.ZERO;
for (String key : List.of("market", "nexus", "logitics", "ledger", "archiveos")) {
Map<String, Object> source = "archiveos".equals(key) ? Map.of("status", "HEALTHY", "name", "ArchiveOS") : map(services.get(key));
Map<String, Object> body = financeBody(map(source.get("summary")));
BigDecimal revenue = revenueFor(key, body);
BigDecimal cost = costFor(key, body);
BigDecimal profit = profitFor(key, body);
if (profit == null && revenue != null && cost != null) profit = revenue.subtract(cost);
BigDecimal cash = amount(body, "cashBalance", "availableCash", "cash", "balance");
BigDecimal backlog = amount(body, "backlog", "pending", "approvalRequired");
if (!"archiveos".equals(key)) {
totalRevenue = totalRevenue.add(orZero(revenue));
totalCost = totalCost.add(orZero(cost));
totalProfit = totalProfit.add(orZero(profit));
}
rows.add(row(key, source, body, revenue, cost, profit, cash, backlog));
}
for (Map<String, Object> row : rows) {
row.put("revenueShare", ratio(decimal(row.get("revenue")), totalRevenue));
row.put("expenseShare", ratio(decimal(row.get("cost")), totalCost));
BigDecimal rowProfit = decimal(row.get("profit"));
row.put("profitShare", ratio(rowProfit == null ? null : rowProfit.max(BigDecimal.ZERO), totalProfit.max(BigDecimal.ZERO)));
enrichBalance(row);
}
Map<String, Object> targetMargins = new LinkedHashMap<>();
for (String key : List.of("market", "nexus", "logistics", "ledger", "archiveos")) {
EcosystemBalanceProperties.Margin margin = policy.marginFor(key);
targetMargins.put(key, margin.getMinMargin() + "-" + margin.getMaxMargin() + "%");
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("syntheticData", true);
result.put("targetMargins", targetMargins);
result.put("policy", Map.of("backlogWarning", policy.getBacklogWarning(), "capacityWarningPercent", policy.getCapacityWarningPercent(), "profitConcentrationPercent", policy.getProfitConcentrationPercent()));
result.put("totals", Map.of("revenue", totalRevenue, "cost", totalCost, "profit", totalProfit));
result.put("services", rows);
result.put("balanceStatus", balanceStatus(rows));
result.put("reviewReason", reviewReason(rows));
return result;
}

public Map<String, Object> recommendations() {
Map<String, Object> summary = summary();
@SuppressWarnings("unchecked") List<Map<String, Object>> rows = (List<Map<String, Object>>) summary.get("services");
List<Map<String, Object>> actions = new ArrayList<>();
for (Map<String, Object> row : rows) {
String service = String.valueOf(row.get("serviceId"));
String balance = String.valueOf(row.get("balance"));
BigDecimal backlog = decimal(row.get("backlog"));
if ("CONCENTRATED".equals(balance)) actions.add(action(service, "수익 집중", String.valueOf(row.get("balanceReason")), "READ_ONLY"));
if ("UNDER_PRESSURE".equals(balance)) actions.add(action(service, "손익 압박", String.valueOf(row.get("balanceReason")), "READ_ONLY"));
if (backlog != null && backlog.compareTo(BigDecimal.valueOf(policy.getBacklogWarning())) > 0) actions.add(action(service, "적체 증가", "처리 대기량이 정책 경고 기준을 초과했습니다. 작업 역량과 자동 처리 상태를 확인하세요.", "READ_ONLY"));
BigDecimal capacity = decimal(row.get("capacityUtilization"));
if (capacity != null && capacity.compareTo(BigDecimal.valueOf(policy.getCapacityWarningPercent())) >= 0) actions.add(action(service, "처리 역량 주의", "처리 역량 사용률이 정책 경고 기준을 초과했습니다.", "READ_ONLY"));
}
if (actions.isEmpty()) actions.add(action("archive-platform", "균형 범위", "현재 수집된 합성 지표에서 즉시 조정이 필요한 불균형은 없습니다.", "READ_ONLY"));
return Map.of("syntheticData", true, "recommendations", actions);
}

public Map<String, Object> simulate(Map<String, Object> request) {
return Map.of("status", "DRY_RUN", "syntheticData", true, "message", "외부 수수료나 자금은 변경하지 않습니다.", "current", summary(), "request", request == null ? Map.of() : request);
}

private Map<String, Object> row(String key, Map<String, Object> source, Map<String, Object> body, BigDecimal revenue, BigDecimal cost, BigDecimal profit, BigDecimal cash, BigDecimal backlog) {
EcosystemBalanceProperties.Margin target = policy.marginFor(key);
BigDecimal margin = revenue == null || revenue.signum() == 0 || profit == null ? null : profit.multiply(BigDecimal.valueOf(100)).divide(revenue, 2, RoundingMode.HALF_UP);
Map<String, Object> row = new LinkedHashMap<>();
row.put("serviceId", "archiveos".equals(key) ? "archiveos" : "logitics".equals(key) ? "archive-logistics" : "archive-" + key);
row.put("serviceName", "archiveos".equals(key) ? "ArchiveOS" : string(source.get("name"), "Archive-" + key));
row.put("status", "archiveos".equals(key) ? "HEALTHY" : string(source.get("status"), "UNKNOWN"));
row.put("financeSource", key + " latest summary");
row.put("revenue", revenue); row.put("cost", cost); row.put("profit", profit); row.put("cashBalance", cash); row.put("backlog", backlog);
row.put("targetMinMargin", target.getMinMargin()); row.put("targetMaxMargin", target.getMaxMargin()); row.put("operatingMargin", margin);
row.put("marginGap", margin == null ? null : margin.compareTo(target.getMinMargin()) < 0 ? margin.subtract(target.getMinMargin()) : margin.compareTo(target.getMaxMargin()) > 0 ? margin.subtract(target.getMaxMargin()) : BigDecimal.ZERO);
row.put("capacityUtilization", firstAmount(body, "capacityUtilization", "usedCapacityPercent"));
row.put("approvalBacklog", firstAmount(body, "approvalBacklog", "approvalRequired"));
row.put("settlementBacklog", firstAmount(body, "settlementBacklog", "settlementPending"));
row.put("feeConcentration", firstAmount(body, "feeConcentration"));
row.put("negativeProfitStreak", firstAmount(body, "negativeProfitStreak"));
return row;
}

private void enrichBalance(Map<String, Object> row) {
BigDecimal margin = decimal(row.get("operatingMargin"));
BigDecimal min = decimal(row.get("targetMinMargin"));
BigDecimal max = decimal(row.get("targetMaxMargin"));
if (margin == null || min == null || max == null) { row.put("balance", "NO_DATA"); row.put("balanceReason", "손익률을 판단할 수 있는 합성 재무 데이터가 아직 수집되지 않았습니다."); return; }
if (margin.compareTo(max) > 0) { row.put("balance", "CONCENTRATED"); row.put("balanceReason", "영업이익률이 정책 상한을 초과했습니다."); return; }
if (margin.compareTo(min) < 0) { row.put("balance", "UNDER_PRESSURE"); row.put("balanceReason", "영업이익률이 정책 하한보다 낮습니다."); return; }
row.put("balance", "WITHIN_RANGE"); row.put("balanceReason", "영업이익률이 정책 목표 범위 안에 있습니다.");
}

private Map<String, Object> action(String service, String title, String reason, String mode) { return Map.of("serviceId", service, "title", title, "reason", reason, "mode", mode); }
private String balanceStatus(List<Map<String, Object>> rows) { long available = rows.stream().filter(row -> !"NO_DATA".equals(row.get("balance"))).count(); if (available == 0) return "NO_DATA"; if (available < rows.size()) return "PARTIAL_DATA"; return rows.stream().anyMatch(row -> "UNDER_PRESSURE".equals(row.get("balance")) || "CONCENTRATED".equals(row.get("balance")) || concentrationExceeded(row)) ? "COMPLETE_REVIEW" : "COMPLETE_BALANCED"; }
private String reviewReason(List<Map<String, Object>> rows) { long missing = rows.stream().filter(row -> "NO_DATA".equals(row.get("balance"))).count(); if (missing == rows.size()) return "수집된 재무 데이터가 없어 생태계 균형을 평가할 수 없습니다."; if (missing > 0) return "일부 서비스의 재무 데이터가 아직 수집되지 않아 생태계 균형은 부분 평가 상태입니다."; return rows.stream().filter(row -> "UNDER_PRESSURE".equals(row.get("balance"))).findFirst().map(row -> row.get("serviceName") + " 손익이 권장 범위 아래입니다.").orElse("현재 수집된 합성 지표는 균형 범위에 있습니다."); }
private boolean concentrationExceeded(Map<String, Object> row) { BigDecimal share = decimal(row.get("profitShare")); return share != null && share.compareTo(BigDecimal.valueOf(policy.getProfitConcentrationPercent())) > 0; }
@SuppressWarnings("unchecked") private Map<String, Object> map(Object value) { return value instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of(); }
private String string(Object value, String fallback) { return value == null || String.valueOf(value).isBlank() ? fallback : String.valueOf(value); }
private BigDecimal amount(Map<String, Object> source, String... keys) { for (String key : keys) if (source.containsKey(key) && source.get(key) != null) return decimal(source.get(key)); return null; }
private BigDecimal firstAmount(Map<String, Object> source, String... keys) { return amount(source, keys); }
private BigDecimal decimal(Object value) { try { return value == null ? null : new BigDecimal(String.valueOf(value)); } catch (NumberFormatException error) { return null; } }
private BigDecimal orZero(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private BigDecimal ratio(BigDecimal value, BigDecimal total) { return value == null || total.signum() == 0 ? null : value.multiply(BigDecimal.valueOf(100)).divide(total, 2, RoundingMode.HALF_UP); }
private BigDecimal revenueFor(String key, Map<String, Object> body) { return switch (key) { case "market" -> amount(body, "recognizedRevenue", "totalRevenue", "revenue"); case "nexus" -> amount(body, "manufacturingRevenue", "totalRevenue", "revenue"); case "logitics" -> amount(body, "logisticsRevenue", "totalRevenue", "revenue"); case "ledger" -> amount(body, "settlementAgencyRevenue", "totalRevenue", "revenue"); default -> amount(body, "costRecoveryRevenue", "totalRevenue", "revenue"); }; }
private BigDecimal costFor(String key, Map<String, Object> body) { return switch (key) { case "market" -> amount(body, "totalExpense", "totalCost", "cost"); case "nexus" -> amount(body, "totalCost", "materialCost", "maintenanceCost", "qualityLossCost", "workforceCost"); case "logitics" -> amount(body, "totalCost", "fuelCost", "tollCost", "workforceCost", "delayPenaltyCost"); case "ledger" -> amount(body, "operatingCost", "totalCost", "cost"); default -> amount(body, "operatingCost", "totalCost", "cost"); }; }
private BigDecimal profitFor(String key, Map<String, Object> body) { return amount(body, "operatingProfit", "profit", "profitAmount"); }
private Map<String, Object> financeBody(Map<String, Object> source) { Map<String, Object> result = new LinkedHashMap<>(source); for (String key : List.of("data", "summary", "economy", "marketEconomy", "settlementAgency", "cashflow", "workforce")) if (result.get(key) instanceof Map<?, ?>) result.putAll(financeBody(map(result.get(key)))); return result; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static class ServiceConfig {
private String baseUrl;
private String healthPath = "/actuator/health";
private String summaryPath;
private String operationsSummaryPath;
private String outboxSummaryPath;
private String routeSummaryPath;
private String approvalRequiredPath;
Expand All @@ -55,6 +56,8 @@ public static class ServiceConfig {
public void setHealthPath(String healthPath) { this.healthPath = healthPath; }
public String getSummaryPath() { return summaryPath; }
public void setSummaryPath(String summaryPath) { this.summaryPath = summaryPath; }
public String getOperationsSummaryPath() { return operationsSummaryPath; }
public void setOperationsSummaryPath(String operationsSummaryPath) { this.operationsSummaryPath = operationsSummaryPath; }
public String getOutboxSummaryPath() { return outboxSummaryPath; }
public void setOutboxSummaryPath(String outboxSummaryPath) { this.outboxSummaryPath = outboxSummaryPath; }
public String getRouteSummaryPath() { return routeSummaryPath; }
Expand Down
Loading
Loading