시작하기#
기본 개념#
우리 업무 환경과 협력사 사이에서 파일을 양방향으로 교환하기
협력사와 진행하는 업무에서는 주문서와 설계 자료처럼 협력사로 보내는 파일과 검사 결과, 정산 자료처럼 협력사에서 받는 파일이 함께 사용됩니다.
파일 교환은 우리 업무 환경과 협력사 시스템을 연결하고, 송신과 수신 파일을 각각의 경로와 조건에 따라 관리하는 방식입니다.
우리 업무 환경
│
주문서 · 설계 자료 │
▼
┌────────────────┐
│ File Exchange │
└────────────────┘
▲
검사 결과 · 정산 자료 │
│
협력사 시스템
협력사별로 교환할 파일과 경로를 구성하면 주문, 설계, 정산, 검사 등 업무에 맞는 양방향 파일 흐름을 관리할 수 있습니다.
교환 흐름#
파일을 보내고 결과 파일을 받아 다음 업무까지 이어가기
내부에서 생성한 업무 파일은 협력사로 전송하고, 협력사에서 처리한 결과 파일은 다시 수신해 다음 업무에 활용합니다.
업무 파일 준비
│
▼
협력사로 전송
│
▼
협력사 업무 처리
│
▼
결과 파일 수신
│
▼
내부 후속 업무
송신과 수신을 하나의 업무 흐름으로 연결하면 파일이 협력사와 내부 업무 사이를 오가는 과정을 함께 관리할 수 있습니다.
업무 효과#
협력사별 파일 교환을 정해진 업무 흐름으로 관리하기
협력사마다 필요한 파일과 업무가 다르더라도 협력사별 파일과 교환 경로를 구분해 업무에 맞는 흐름으로 활용할 수 있습니다.
| 업무 | 협력사로 전송 | 협력사에서 수신 | 활용 업무 |
|---|---|---|---|
| 주문 | 주문서 · 발주 자료 | 주문 결과 | 주문 처리 |
| 정산 | 정산 요청 자료 | 정산 결과 | 정산 확인 |
| 설계 | 설계 자료 | 수정 · 검토 자료 | 설계 검토 |
| 검사 | 검사 요청 자료 | 검사 결과 | 품질 확인 |
업무팀#
파일 교환#
업무에 필요한 파일을 선택해 협력사와 주고받기
업무팀은 주문, 정산, 설계, 검사 등 현재 진행하는 업무에 필요한 파일을 선택해 협력사별 파일 교환 작업을 실행합니다.
파일 선택
│
▼
협력사 선택
│
▼
파일 전송
│
▼
결과 파일 수신수신된 파일은 검토, 수정, 승인 등 다음 업무에서 바로 활용할 수 있습니다.
교환 현황#
협력사별 파일 교환 상태와 결과 확인하기
여러 협력사와 업무를 진행하는 경우 협력사별로 보낸 파일과 받은 파일의 처리 상태를 확인할 수 있습니다.
| 협력사 | 송신 파일 | 수신 파일 | 진행 상태 |
|---|---|---|---|
| 협력사 A | 주문서 | 주문 결과 | 완료 |
| 협력사 B | 설계 자료 | 수정 자료 | 진행 중 |
| 협력사 C | 검사 요청 | 검사 결과 | 완료 |

이를 통해 업무팀은 현재 어떤 협력사와 파일 교환이 진행되고 있는지 확인하고, 수신된 결과를 다음 업무에 활용할 수 있습니다.
IT 엔지니어#
협력사 연결#
협력사 시스템과 내부 파일 교환 환경 연결하기
먼저 파일을 교환할 협력사 시스템과 내부 파일 전송 환경을 연결합니다.
여러 협력사와 파일을 교환하는 경우 각 협력사를 개별 연결 대상으로 구성할 수 있습니다.
File Exchange
│
┌──────────────┼──────────────┐
▼ ▼ ▼
Partner A Partner B Partner C
↕ ↕ ↕
주문 업무 설계 업무 정산 업무
교환 설정#
송신·수신 경로와 실행 기준을 하나의 흐름으로 구성하기
협력사별로 내부에서 보낼 파일의 경로와 협력사에서 받을 파일의 경로를 설정합니다.
파일 이름과 경로, 유형을 기준으로 교환 대상을 구성하고, 정해진 시간이나 파일 준비·도착 조건에 따라 작업을 시작하도록 설정할 수 있습니다.
우리 시스템 협력사 시스템
/send/orders ───────── 전송 ────────► /receive/orders
/receive/results ◄───── 수신 ──────── /send/results| 구분 | 우리 시스템 | 협력사 시스템 | 실행 기준 |
|---|---|---|---|
| 송신 | /send/orders |
/receive/orders |
일정 · 파일 준비 |
| 수신 | /receive/results |
/send/results |
일정 · 파일 도착 |

송신과 수신 작업을 함께 구성하면 협력사와의 파일 교환을 하나의 양방향 흐름으로 관리할 수 있습니다.
교환 흐름#
여러 협력사의 파일 교환 작업을 업무별로 연결하기
각 협력사와의 파일 교환은 송신과 수신 작업을 조합해 하나의 업무 흐름으로 구성할 수 있습니다.
┌───────────────┐
│ 협력사별 파일 흐름 │
└───────┬───────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
Partner A Partner B Partner C
│ │ │
주문 교환 설계 교환 정산 교환
│ │ │
└──────────┼──────────┘
▼
내부 후속 업무운영 확인#
협력사별 송신과 수신 결과를 함께 관리하기
Runs와 상세 실행 정보를 통해 각 협력사의 파일 교환 작업과 처리 결과를 확인할 수 있습니다.
송신과 수신 결과를 협력사별로 확인하면 현재 진행 중인 작업과 완료된 파일 교환 현황을 함께 관리할 수 있습니다.
Partner A
├── Send → Completed
└── Receive → Completed
Partner B
├── Send → Completed
└── Receive → In Progress
작업 중 확인이 필요한 경우 실행 상세 정보와 처리 기록을 기준으로 연결 상태와 파일 경로, 처리 결과를 확인한 후 필요한 작업을 다시 실행할 수 있습니다.
개발자#
협력사 장비로 파일을 보내고 협력사가 올린 파일을 감시로 가져오기
연동 준비#
공통 호출 코드와 경로 표기 준비하기
import os
import requests
BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com").rstrip("/")
TOKEN = os.environ["INNORIX_ACCESS_TOKEN"]
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID") # optional; falls back to the current workspace
STATUS_COMPLETE = 2
TERMINAL = {2, 4, 5, 9, 99} # complete / error / cancelled / partial / failed
NOT_SUCCEEDED = {4, 5, 9, 99}
def api(method, path, body=None, params=None):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}",
}
if WORKSPACE_ID:
headers["x-workspace-id"] = WORKSPACE_ID
response = requests.request(
method, BASE_URL + path,
headers=headers, json=body, params=params, timeout=30,
)
payload = response.json() if response.content else {}
if not response.ok:
raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
return payload.get("data")
def is_terminal(detail):
return detail.get("isTerminal", detail.get("status") in TERMINAL)// InnorixClient.java
public static final String BASE_URL =
env("INNORIX_BASE_URL", "https://app.innorix.com").replaceAll("/+quot;, "");
public static final String WORKSPACE_ID = env("INNORIX_WORKSPACE_ID", null);
public static final int STATUS_COMPLETE = 2;
// States the transfer no longer moves out of
public static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99);
// Terminal states that are not a full success
public static final Set<Integer> NOT_SUCCEEDED = Set.of(4, 5, 9, 99);
private HttpRequest.Builder headers(HttpRequest.Builder builder) {
builder.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + session.accessToken());
// When omitted the account's current workspace is used.
if (workspaceId != null) builder.header("x-workspace-id", workspaceId);
return builder;
}
/** Unwraps and returns data from the response. Throws ApiError on failure. */
public Object api(String method, String path, Object body, Map<String, Object> params) {
Resp response = request(method, path, body, params);
Object payload = null;
try {
payload = Json.parse(response.text());
} catch (RuntimeException ignored) {
payload = null;
}
if (!response.ok()) {
Map<String, Object> map = Json.asObj(payload);
String message = Json.str(map, "message", Json.str(map, "error", "unknown error"));
throw new ApiError(response.status, message, map);
}
return Json.get(payload, "data");
}
/** Use the server flag when present, otherwise fall back to the status code. */
public static boolean isTerminal(Map<String, Object> record) {
Boolean flag = Json.boolOrNull(record, "isTerminal");
if (flag != null) return flag;
Integer status = Json.intOrNull(record, "status");
return status != null && TERMINAL.contains(status);
}// innorix-client.js
const BASE_URL = (process.env.INNORIX_BASE_URL
|| "https://app.innorix.com").replace(/\/+$/, "");
const TOKEN = process.env.INNORIX_ACCESS_TOKEN;
const WORKSPACE_ID = process.env.INNORIX_WORKSPACE_ID || null;
export const STATUS_COMPLETE = 2;
export const TERMINAL = new Set([2, 4, 5, 9, 99]); // complete / error / cancelled / partial / failed
export const NOT_SUCCEEDED = new Set([4, 5, 9, 99]);
export async function api(method, path, body = null, params = null) {
const url = new URL(BASE_URL + path);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, String(value));
}
}
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
};
// When omitted the account's current workspace is used.
if (WORKSPACE_ID) headers["x-workspace-id"] = WORKSPACE_ID;
const response = await fetch(url, {
method,
headers,
body: body === null ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || `HTTP ${response.status}`);
}
return payload.data;
}
export function isTerminal(detail) {
return detail.isTerminal !== undefined
? detail.isTerminal
: TERMINAL.has(detail.status);
}// InnorixClient.cs
public static readonly string BaseUrl =
Env("INNORIX_BASE_URL", "https://app.innorix.com").TrimEnd('/');
public static readonly string WorkspaceIdFromEnv = Env("INNORIX_WORKSPACE_ID", null);
public const int StatusComplete = 2;
/// <summary>States the transfer no longer moves out of</summary>
public static readonly HashSet<int> Terminal = new HashSet<int> { 2, 4, 5, 9, 99 };
/// <summary>Terminal states that are not a full success</summary>
public static readonly HashSet<int> NotSucceeded = new HashSet<int> { 4, 5, 9, 99 };
// Applied on every request
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Session.AccessToken);
// When omitted the account's current workspace is used.
if (WorkspaceId != null) request.Headers.TryAddWithoutValidation("x-workspace-id", WorkspaceId);
public async Task<JsonNode> ApiAsync(string method, string path, JsonNode body = null,
IDictionary<string, object> parameters = null)
{
Resp response = await RequestAsync(method, path, body, parameters).ConfigureAwait(false);
JsonNode payload = null;
try
{
payload = J.Parse(response.Text());
}
catch (Exception)
{
payload = null;
}
if (!response.Ok)
{
JsonObject map = J.AsObj(payload);
string message = J.Str(map, "message", J.Str(map, "error", "unknown error"));
throw new ApiError(response.Status, message, map);
}
return J.Get(payload, "data");
}
/// <summary>Use the server flag when present, otherwise fall back to the status code.</summary>
public static bool IsTerminal(JsonObject record)
{
bool? flag = J.BoolOrNull(record, "isTerminal");
if (flag != null) return flag.Value;
int? status = J.IntOrNull(record, "status");
return status != null && Terminal.Contains(status.Value);
}import base64
import time
def encode_path(device_id, raw_path):
normalized = str(raw_path or "").replace("\\", "/")
token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
return f"{device_id}_ino_{token}"
def now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())public static String encodePath(String deviceId, String rawPath) {
String normalized = (rawPath == null ? "" : rawPath).replace("\\", "/");
return deviceId + "_ino_"
+ Base64.getEncoder().encodeToString(normalized.getBytes(StandardCharsets.UTF_8));
}
public static String nowIso() {
return Instant.now().truncatedTo(ChronoUnit.SECONDS).toString().replace("Z", ".000Z");
}export function encodePath(deviceId, rawPath) {
const normalized = String(rawPath ?? "").replace(/\\/g, "/");
const token = Buffer.from(normalized, "utf8").toString("base64");
return `${deviceId}_ino_${token}`;
}
export function nowIso() {
return new Date().toISOString().replace(/\.\d{3}Z$/, ".000Z");
}public static string EncodePath(string deviceId, string rawPath)
{
string normalized = (rawPath ?? "").Replace("\\", "/");
return deviceId + "_ino_" + Convert.ToBase64String(Encoding.UTF8.GetBytes(normalized));
}
public static string NowIso()
{
return DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'.000Z'",
System.Globalization.CultureInfo.InvariantCulture);
}전송 상태는 아래 값으로 판단합니다. 종료 상태는 다섯 개이고 성공에 해당하는 값은 완료(2)입니다.
| 상태 값 | 의미 | 종료 |
|---|---|---|
| 2 | 완료 | 예 |
| 4 | 오류 | 예 |
| 5 | 취소 | 예 |
| 9 | 부분 완료 | 예 |
| 99 | 실패 | 예 |
| 1 · 6 · 12 · 13 | 시작·전송 중·동기화 중·수신 중 | 아니오 |
협력사 구성#
협력사별 장비와 경로를 데이터로 관리하기
협력사마다 경로와 업무가 다릅니다. 설정을 데이터로 두면 협력사가 늘어도 코드를 고치지 않습니다.
PARTNERS = {
"partner-a": {
"device": "device-partner-a",
"send": {"local": "/send/orders", "remote": "/receive/orders"},
"receive": {"remote": "/send/results", "local": "/receive/results"},
},
"partner-b": {
"device": "device-partner-b",
"send": {"local": "/send/design", "remote": "/receive/design"},
"receive": {"remote": "/send/review", "local": "/receive/review"},
},
}
INTERNAL = "device-hq-01"record Route(String local, String remote) {}
record Partner(String device, Route send, Route receive) {}
static final Map<String, Partner> PARTNERS = Map.of(
"partner-a", new Partner("device-partner-a",
new Route("/send/orders", "/receive/orders"),
new Route("/receive/results", "/send/results")),
"partner-b", new Partner("device-partner-b",
new Route("/send/design", "/receive/design"),
new Route("/receive/review", "/send/review")));
static final String INTERNAL = "device-hq-01";export const PARTNERS = {
"partner-a": {
device: "device-partner-a",
send: { local: "/send/orders", remote: "/receive/orders" },
receive: { remote: "/send/results", local: "/receive/results" },
},
"partner-b": {
device: "device-partner-b",
send: { local: "/send/design", remote: "/receive/design" },
receive: { remote: "/send/review", local: "/receive/review" },
},
};
export const INTERNAL = "device-hq-01";public record Route(string Local, string Remote);
public record Partner(string Device, Route Send, Route Receive);
static readonly Dictionary<string, Partner> Partners = new()
{
["partner-a"] = new Partner("device-partner-a",
new Route("/send/orders", "/receive/orders"),
new Route("/receive/results", "/send/results")),
["partner-b"] = new Partner("device-partner-b",
new Route("/send/design", "/receive/design"),
new Route("/receive/review", "/send/review")),
};
const string Internal = "device-hq-01";작업 공간을 협력사별로 나눠 쓴다면 요청마다 작업 공간 식별자를 함께 보냅니다. 한 협력사의 실수로 다른 협력사의 파일에 접근하는 상황을 막습니다.
파일 송신#
내부 파일을 협력사 장비로 보내기
파일을 보낼 때는 sourceItem에 isDir: false로 명시합니다. sourcePaths는 모든 경로를 폴더로 취급합니다.
import os
def send_to_partner(partner_id, files):
partner = PARTNERS[partner_id]
transfer = api("POST", "/api/transfers/manual", {
"sourceDevice": INTERNAL,
"targetDevice": partner["device"],
"targetPath": partner["send"]["remote"],
"sourceItem": [{"path": p, "isDir": False} for p in files],
"sendAllFolder": False,
"transferOptions": {"target-action": "numbering"},
})
return transfer["monitorId"]
monitor_id = send_to_partner("partner-a", ["/send/orders/PO-2026-0901.xlsx"])String sendToPartner(String partnerId, List<String> files) {
Partner partner = PARTNERS.get(partnerId);
List<Object> items = new ArrayList<>();
for (String path : files) items.add(Json.newObj("path", path, "isDir", false));
Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
"sourceDevice", INTERNAL,
"targetDevice", partner.device(),
"targetPath", partner.send().remote(),
"sourceItem", items,
"sendAllFolder", false,
"transferOptions", Json.newObj("target-action", "numbering")));
return Json.str(transfer, "monitorId");
}
String monitorId = sendToPartner("partner-a", List.of("/send/orders/PO-2026-0901.xlsx"));async function sendToPartner(partnerId, files) {
const partner = PARTNERS[partnerId];
const transfer = await client.api("POST", "/api/transfers/manual", {
sourceDevice: INTERNAL,
targetDevice: partner.device,
targetPath: partner.send.remote,
sourceItem: files.map((path) => ({ path, isDir: false })),
sendAllFolder: false,
transferOptions: { "target-action": "numbering" },
});
return transfer.monitorId;
}
const monitorId = await sendToPartner("partner-a", ["/send/orders/PO-2026-0901.xlsx"]);async Task<string> SendToPartnerAsync(string partnerId, IEnumerable<string> files)
{
Partner partner = Partners[partnerId];
var items = new JsonArray();
foreach (string path in files)
items.Add(new JsonObject { ["path"] = path, ["isDir"] = false });
JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
new JsonObject
{
["sourceDevice"] = Internal,
["targetDevice"] = partner.Device,
["targetPath"] = partner.Send.Remote,
["sourceItem"] = items,
["sendAllFolder"] = false,
["transferOptions"] = new JsonObject { ["target-action"] = "numbering" },
});
return J.Str(transfer, "monitorId");
}
string monitorId = await SendToPartnerAsync("partner-a",
new[] { "/send/orders/PO-2026-0901.xlsx" });폴더 단위로 보낼 때는 sourcePaths와 sendAllFolder: True를 씁니다.
협력사로 보내는 파일은 회차를 남겨야 하므로 numbering을 씁니다. 같은 주문서를 수정해 다시 전송하는 경우 이전 본이 사라지면 어느 것을 기준으로 처리했는지 확인할 수 없습니다.
결과 파일 수신#
협력사가 올린 파일을 감지해 가져오기
협력사가 언제 파일을 올릴지 알 수 없으므로 실시간 감시 자동화로 구성합니다. transferType을 sync로 두고 transferOptions에 watchFolderType을 넣으면 파일이 도착하는 즉시 전송됩니다.
def create_receive_watch(partner_id, webhook=None):
partner = PARTNERS[partner_id]
remote = partner["receive"]["remote"]
local = f"{partner['receive']['local']}/{partner_id}"
name = f"receive {partner_id}"
body = {
"name": name,
"flowName": name,
"transferType": "sync",
"timezone": "Asia/Seoul",
"step": 1,
"isUpcoming": False,
"details": [
{
"senderId": partner["device"],
"receiverId": INTERNAL,
"sourceItem": [
{
"hash": encode_path(partner["device"], remote),
"filePath": remote,
"isDir": True,
}
],
"targetPath": encode_path(INTERNAL, local),
"step": 1,
"transferOptions": {
"noSchedule": True,
"target-action": "numbering",
"send-fileoption": {},
"syncType": 1,
"watchFolderType": 1, # 1 = on create, 2 = on modify
},
}
],
"schedules": [
{"type": "none", "startDateType": "now",
"startDate": now_iso(), "timezone": "Asia/Seoul"}
],
}
if webhook:
body["processors"] = [{
"category": "run",
"type": "http",
"config": {"url": webhook, "method": "POST"},
}]
return api("POST", "/api/automations", body)["automationId"]
receivers = {p: create_receive_watch(p, RECEIVE_HOOK) for p in PARTNERS}String createReceiveWatch(String partnerId, String webhook) {
Partner partner = PARTNERS.get(partnerId);
String remote = partner.receive().remote();
String local = partner.receive().local() + "/" + partnerId;
String name = "receive " + partnerId;
Map<String, Object> detail = Json.newObj(
"senderId", partner.device(),
"receiverId", INTERNAL,
"sourceItem", List.of(Json.newObj(
"hash", InnorixClient.encodePath(partner.device(), remote),
"filePath", remote,
"isDir", true)),
"targetPath", InnorixClient.encodePath(INTERNAL, local),
"step", 1,
"transferOptions", Json.newObj(
"noSchedule", true,
"target-action", "numbering",
"send-fileoption", Json.newObj(),
"syncType", 1,
"watchFolderType", 1)); // 1 = on create, 2 = on modify
Map<String, Object> body = new LinkedHashMap<>(Json.newObj(
"name", name, "flowName", name,
"transferType", "sync",
"timezone", "Asia/Seoul",
"step", 1, "isUpcoming", false,
"details", List.of(detail),
"schedules", List.of(Json.newObj(
"type", "none", "startDateType", "now",
"startDate", InnorixClient.nowIso(), "timezone", "Asia/Seoul"))));
if (webhook != null) {
body.put("processors", List.of(Json.newObj(
"category", "run", "type", "http",
"config", Json.newObj("url", webhook, "method", "POST"))));
}
return Json.str(client.apiObj("POST", "/api/automations", body), "automationId");
}async function createReceiveWatch(partnerId, webhook = null) {
const partner = PARTNERS[partnerId];
const remote = partner.receive.remote;
const local = `${partner.receive.local}/${partnerId}`;
const name = `receive ${partnerId}`;
const body = {
name,
flowName: name,
transferType: "sync",
timezone: "Asia/Seoul",
step: 1,
isUpcoming: false,
details: [{
senderId: partner.device,
receiverId: INTERNAL,
sourceItem: [{
hash: encodePath(partner.device, remote),
filePath: remote,
isDir: true,
}],
targetPath: encodePath(INTERNAL, local),
step: 1,
transferOptions: {
noSchedule: true,
"target-action": "numbering",
"send-fileoption": {},
syncType: 1,
watchFolderType: 1, // 1 = on create, 2 = on modify
},
}],
schedules: [{
type: "none",
startDateType: "now",
startDate: nowIso(),
timezone: "Asia/Seoul",
}],
};
if (webhook) {
body.processors = [{
category: "run",
type: "http",
config: { url: webhook, method: "POST" },
}];
}
const created = await client.api("POST", "/api/automations", body);
return created.automationId;
}async Task<string> CreateReceiveWatchAsync(string partnerId, string webhook = null)
{
Partner partner = Partners[partnerId];
string remote = partner.Receive.Remote;
string local = quot;{partner.Receive.Local}/{partnerId}";
string name = quot;receive {partnerId}";
var body = new JsonObject
{
["name"] = name,
["flowName"] = name,
["transferType"] = "sync",
["timezone"] = "Asia/Seoul",
["step"] = 1,
["isUpcoming"] = false,
["details"] = new JsonArray
{
new JsonObject
{
["senderId"] = partner.Device,
["receiverId"] = Internal,
["sourceItem"] = new JsonArray
{
new JsonObject
{
["hash"] = InnorixClient.EncodePath(partner.Device, remote),
["filePath"] = remote,
["isDir"] = true,
},
},
["targetPath"] = InnorixClient.EncodePath(Internal, local),
["step"] = 1,
["transferOptions"] = new JsonObject
{
["noSchedule"] = true,
["target-action"] = "numbering",
["send-fileoption"] = new JsonObject(),
["syncType"] = 1,
["watchFolderType"] = 1, // 1 = on create, 2 = on modify
},
},
},
["schedules"] = new JsonArray
{
new JsonObject
{
["type"] = "none",
["startDateType"] = "now",
["startDate"] = InnorixClient.NowIso(),
["timezone"] = "Asia/Seoul",
},
},
};
if (webhook != null)
{
body["processors"] = new JsonArray
{
new JsonObject
{
["category"] = "run",
["type"] = "http",
["config"] = new JsonObject { ["url"] = webhook, ["method"] = "POST" },
},
};
}
JsonObject created = await client.ApiObjAsync("POST", "/api/automations", body);
return J.Str(created, "automationId");
}자동화 요청에서 반드시 지켜야 하는 항목이 네 개 있습니다.
| 항목 | 지정 방식 |
|---|---|
isUpcoming |
반드시 false. 서버 기본값 true는 요청에 담긴 일정을 무시하고 5분짜리 일회성 일정으로 대체합니다. triggerAutomation이 붙은 단계는 서버가 false로 강제하므로, 트리거가 없는 첫 단계에만 직접 지정하면 됩니다 |
step |
최상위와 details 양쪽에 넣습니다. 흐름 안에서의 홉 위치입니다 |
sourceItem |
hash(경로 토큰)와 filePath(평문 경로)를 함께 넣습니다 |
syncType |
transferOptions 안에 넣습니다. 1은 단방향, 2는 양방향입니다 |
네 항목 모두 누락해도 등록은 성공하고 실행 시점에 동작이 달라집니다. 반복 일정을 등록했는데 한 번만 실행되고 끝났다면 isUpcoming부터 확인합니다.
도착 경로에 협력사 식별자를 넣습니다. 협력사마다 result.xlsx처럼 이름이 같으므로 한 경로로 모으면 출처를 구분할 수 없습니다.
에이전트는 파일 크기 변화가 멈추면 쓰기 완료로 판단하고 이벤트를 전달합니다. 협력사가 큰 파일을 올리는 중에 잘린 파일을 가져오는 일은 생기지 않습니다.
수신 후 처리 연결#
결과 파일이 도착하면 내부 업무 시작하기
호출은 전송 완료 후에 오며, 그 요청을 받는 엔드포인트는 다음과 같이 처리합니다.
def on_receive_hook(payload):
monitor_id = payload.get("monitorId")
if monitor_id:
detail = api("GET", f"/api/transfers/{monitor_id}")
if detail["status"] != STATUS_COMPLETE:
return notify_partner_failure(payload)
register_received_files(payload)void onReceiveHook(Map<String, Object> payload) {
String monitorId = Json.str(payload, "monitorId");
if (monitorId != null) {
Map<String, Object> detail = client.apiObj("GET", "/api/transfers/" + monitorId);
if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
notifyPartnerFailure(payload);
return;
}
}
registerReceivedFiles(payload);
}async function onReceiveHook(payload) {
const monitorId = payload.monitorId;
if (monitorId) {
const detail = await client.api("GET", `/api/transfers/${monitorId}`);
if (detail.status !== STATUS_COMPLETE) return notifyPartnerFailure(payload);
}
return registerReceivedFiles(payload);
}async Task OnReceiveHookAsync(JsonObject payload)
{
string monitorId = J.Str(payload, "monitorId");
if (monitorId != null)
{
JsonObject detail = await client.ApiObjAsync("GET", "/api/transfers/" + monitorId);
if (J.Int(detail, "status", -1) != InnorixClient.StatusComplete)
{
await NotifyPartnerFailureAsync(payload);
return;
}
}
await RegisterReceivedFilesAsync(payload);
}같은 전송에 대해 알림이 여러 번 올 수 있으므로, 받는 쪽은 같은 이벤트를 여러 번 받아도 한 번만 처리되도록 만듭니다.
교환 현황 조회#
협력사별 송신과 수신 상태 확인하기
from datetime import datetime, timedelta, timezone
def paginate(path, params=None, limit=200, max_pages=50):
query = dict(params or {})
query["limit"] = limit
cursor = None
for _ in range(max_pages):
if cursor:
query["cursor"] = cursor
result = api("GET", path, params=query) or {}
for record in result.get("data") or []:
yield record
pagination = result.get("pagination") or {}
if not pagination.get("hasMore"):
return
cursor = pagination.get("nextCursor")
if not cursor:
return
def partner_summary(partner_id, days=7):
partner = PARTNERS[partner_id]
end = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%SZ"
rows = list(paginate(
f"/api/devices/{partner['device']}/transfer-history", params={
"startDate": (end - timedelta(days=days)).strftime(fmt),
"endDate": end.strftime(fmt),
}))
return {
"sent": len([r for r in rows if r.get("sourceDeviceName") == INTERNAL]),
"received": len([r for r in rows if r.get("targetDeviceName") == INTERNAL]),
"failed": len([r for r in rows if r.get("status") in NOT_SUCCEEDED]),
"rows": rows,
}
for partner_id in PARTNERS:
summary = partner_summary(partner_id)
print(f"{partner_id:14} sent {summary['sent']:>3}"
f" received {summary['received']:>3} failed {summary['failed']:>3}")Map<String, Object> partnerSummary(String partnerId, int days) {
Partner partner = PARTNERS.get(partnerId);
Instant end = Instant.now();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC);
List<Map<String, Object>> rows = client.paginate(
"/api/devices/" + partner.device() + "/transfer-history",
Json.newObj("startDate", fmt.format(end.minus(days, ChronoUnit.DAYS)),
"endDate", fmt.format(end)), 200, 50);
int sent = 0, received = 0, failed = 0;
for (Map<String, Object> row : rows) {
if (INTERNAL.equals(Json.str(row, "sourceDeviceName"))) sent++;
if (INTERNAL.equals(Json.str(row, "targetDeviceName"))) received++;
Integer status = Json.intOrNull(row, "status");
if (status != null && InnorixClient.NOT_SUCCEEDED.contains(status)) failed++;
}
return Json.newObj("sent", sent, "received", received, "failed", failed, "rows", rows);
}async function* paginate(path, params = {}, limit = 200, maxPages = 50) {
const query = { ...params, limit };
let cursor = null;
for (let page = 0; page < maxPages; page += 1) {
if (cursor) query.cursor = cursor;
const result = (await client.api("GET", path, null, query)) || {};
for (const record of result.data || []) yield record;
const pagination = result.pagination || {};
if (!pagination.hasMore || !pagination.nextCursor) return;
cursor = pagination.nextCursor;
}
}
async function partnerSummary(partnerId, days = 7) {
const partner = PARTNERS[partnerId];
const end = new Date();
const fmt = (d) => d.toISOString().replace(/\.\d{3}Z$/, "Z");
const rows = [];
for await (const row of paginate(
`/api/devices/${partner.device}/transfer-history`, {
startDate: fmt(new Date(end.getTime() - days * 86400000)),
endDate: fmt(end),
})) rows.push(row);
return {
sent: rows.filter((r) => r.sourceDeviceName === INTERNAL).length,
received: rows.filter((r) => r.targetDeviceName === INTERNAL).length,
failed: rows.filter((r) => NOT_SUCCEEDED.has(r.status)).length,
rows,
};
}async Task<(int Sent, int Received, int Failed, List<JsonObject> Rows)>
PartnerSummaryAsync(string partnerId, int days = 7)
{
Partner partner = Partners[partnerId];
DateTime end = DateTime.UtcNow;
const string Fmt = "yyyy-MM-dd'T'HH:mm:ss'Z'";
List<JsonObject> rows = await client.PaginateAsync(
quot;/api/devices/{partner.Device}/transfer-history",
new Dictionary<string, object>
{
["startDate"] = end.AddDays(-days).ToString(Fmt),
["endDate"] = end.ToString(Fmt),
});
int sent = rows.Count(r => J.Str(r, "sourceDeviceName") == Internal);
int received = rows.Count(r => J.Str(r, "targetDeviceName") == Internal);
int failed = rows.Count(r =>
{
int? status = J.IntOrNull(r, "status");
return status != null && InnorixClient.NotSucceeded.Contains(status.Value);
});
return (sent, received, failed, rows);
}전송 이력은 data.data 배열로, 페이징 정보는 data.pagination으로 반환됩니다.
예외 대응#
전송 실패와 미수신을 구분해 처리하기
전송이 실패한 것과 협력사가 아직 파일을 올리지 않은 것은 다릅니다. 대응도 달라집니다.
def wait(monitor_id, timeout=3600, interval=3):
deadline = time.time() + timeout
while time.time() < deadline:
detail = api("GET", f"/api/transfers/{monitor_id}")
if is_terminal(detail):
return detail
time.sleep(interval)
raise TimeoutError(monitor_id)
def failed_files(monitor_id):
result = api("GET", f"/api/transfers/{monitor_id}/files", params={
"state": "any", "size": 500,
}) or {}
return [r for r in (result.get("children") or [])
if r.get("status") in NOT_SUCCEEDED]
def retry_failed(monitor_id):
rows = failed_files(monitor_id)
if not rows:
return 0
api("POST", f"/api/transfers/{monitor_id}/retry", {
"filesRetry": [
{"filePath": r["sourceFilePath"], "isDir": bool(r.get("isFolder"))}
for r in rows
]
})
return len(rows)public Map<String, Object> wait(String monitorId, int timeoutSeconds, int intervalSeconds) {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
Map<String, Object> detail = apiObj("GET", "/api/transfers/" + monitorId);
if (isTerminal(detail)) return detail;
sleep(intervalSeconds * 1000L);
}
throw new ApiError(0, "timeout waiting for " + monitorId);
}
public List<Map<String, Object>> failedFiles(String monitorId) {
List<Map<String, Object>> failed = new ArrayList<>();
for (Map<String, Object> row : transferFiles(monitorId)) {
Integer status = Json.intOrNull(row, "status");
if (status != null && NOT_SUCCEEDED.contains(status)) failed.add(row);
}
return failed;
}
public int retryFailed(String monitorId) {
List<Map<String, Object>> rows = failedFiles(monitorId);
if (rows.isEmpty()) return 0;
List<Object> filesRetry = new ArrayList<>();
for (Map<String, Object> row : rows) {
String path = Json.str(row, "sourceFilePath");
if (path == null) continue;
filesRetry.add(Json.newObj("filePath", path, "isDir", Json.bool(row, "isFolder", false)));
}
api("POST", "/api/transfers/" + monitorId + "/retry", Json.newObj("filesRetry", filesRetry));
return rows.size();
}const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function wait(monitorId, { timeout = 3600, interval = 3 } = {}) {
const deadline = Date.now() + timeout * 1000;
while (Date.now() < deadline) {
const detail = await api("GET", `/api/transfers/${monitorId}`);
if (isTerminal(detail)) return detail;
await sleep(interval * 1000);
}
throw new Error(`timeout waiting for ${monitorId}`);
}
export async function failedFiles(monitorId) {
const result = (await api("GET", `/api/transfers/${monitorId}/files`, null, {
state: "any", size: 500,
})) || {};
return (result.children || []).filter((row) => NOT_SUCCEEDED.has(row.status));
}
export async function retryFailed(monitorId) {
const rows = await failedFiles(monitorId);
if (rows.length === 0) return 0;
await api("POST", `/api/transfers/${monitorId}/retry`, {
filesRetry: rows
.filter((row) => row.sourceFilePath)
.map((row) => ({ filePath: row.sourceFilePath, isDir: Boolean(row.isFolder) })),
});
return rows.length;
}public async Task<JsonObject> WaitAsync(string monitorId, int timeoutSeconds = 3600,
int intervalSeconds = 3)
{
long deadline = Environment.TickCount64 + timeoutSeconds * 1000L;
while (Environment.TickCount64 < deadline)
{
JsonObject detail = await ApiObjAsync("GET", "/api/transfers/" + monitorId)
.ConfigureAwait(false);
if (IsTerminal(detail)) return detail;
await Task.Delay(intervalSeconds * 1000).ConfigureAwait(false);
}
throw new ApiError(0, "timeout waiting for " + monitorId);
}
public async Task<List<JsonObject>> FailedFilesAsync(string monitorId)
{
List<JsonObject> rows = await TransferFilesAsync(monitorId).ConfigureAwait(false);
return rows.Where(row =>
{
int? status = J.IntOrNull(row, "status");
return status != null && NotSucceeded.Contains(status.Value);
}).ToList();
}
/// <summary>Only callable once the transfer has reached a terminal state.</summary>
public async Task<int> RetryFailedAsync(string monitorId)
{
List<JsonObject> rows = await FailedFilesAsync(monitorId).ConfigureAwait(false);
if (rows.Count == 0) return 0;
var filesRetry = new JsonArray();
foreach (JsonObject row in rows)
{
string path = J.Str(row, "sourceFilePath");
if (path == null) continue;
filesRetry.Add(new JsonObject
{
["filePath"] = path,
["isDir"] = J.Bool(row, "isFolder", false),
});
}
await ApiAsync("POST", "/api/transfers/" + monitorId + "/retry",
new JsonObject { ["filesRetry"] = filesRetry }).ConfigureAwait(false);
return rows.Count;
}def check_partner(partner_id, days=1):
partner = PARTNERS[partner_id]
summary = partner_summary(partner_id, days=days)
state = api("GET", f"/api/devices/{partner['device']}/connectivity") or {}
if not state.get("isConnected"):
return f"connection lost ({state.get('stateLabel')}) - recollect after recovery"
if summary["failed"]:
count = 0
for row in summary["rows"]:
if row.get("status") in NOT_SUCCEEDED and row.get("monitorId"):
count += retry_failed(row["monitorId"])
return f"transfer failed - retried {count} files"
if summary["received"] == 0:
return "nothing received - ask the partner"
return "ok"String checkPartner(String partnerId, int days) {
Partner partner = PARTNERS.get(partnerId);
Map<String, Object> summary = partnerSummary(partnerId, days);
Map<String, Object> state = client.apiObj("GET",
"/api/devices/" + partner.device() + "/connectivity");
if (!Json.bool(state, "isConnected", false)) {
return "connection lost (" + Json.str(state, "stateLabel") + ") - recollect after recovery";
}
if (Json.intOr(summary, "failed", 0) > 0) {
int count = 0;
for (Object node : Json.arrOf(summary, "rows")) {
Map<String, Object> row = Json.asObj(node);
Integer status = Json.intOrNull(row, "status");
String monitorId = Json.str(row, "monitorId");
if (status != null && InnorixClient.NOT_SUCCEEDED.contains(status) && monitorId != null) {
count += client.retryFailed(monitorId);
}
}
return "transfer failed - retried " + count + " files";
}
if (Json.intOr(summary, "received", 0) == 0) return "nothing received - ask the partner";
return "ok";
}async function checkPartner(partnerId, days = 1) {
const partner = PARTNERS[partnerId];
const summary = await partnerSummary(partnerId, days);
const state = (await client.api("GET",
`/api/devices/${partner.device}/connectivity`)) || {};
if (!state.isConnected) {
return `connection lost (${state.stateLabel}) - recollect after recovery`;
}
if (summary.failed) {
let count = 0;
for (const row of summary.rows) {
if (NOT_SUCCEEDED.has(row.status) && row.monitorId) {
count += await client.retryFailed(row.monitorId);
}
}
return `transfer failed - retried ${count} files`;
}
if (summary.received === 0) return "nothing received - ask the partner";
return "ok";
}async Task<string> CheckPartnerAsync(string partnerId, int days = 1)
{
Partner partner = Partners[partnerId];
var summary = await PartnerSummaryAsync(partnerId, days);
JsonObject state = await client.ApiObjAsync(
"GET", quot;/api/devices/{partner.Device}/connectivity");
if (!J.Bool(state, "isConnected", false))
{
return quot;connection lost ({J.Str(state, "stateLabel")}) - recollect after recovery";
}
if (summary.Failed > 0)
{
int count = 0;
foreach (JsonObject row in summary.Rows)
{
int? status = J.IntOrNull(row, "status");
string monitorId = J.Str(row, "monitorId");
if (status != null && InnorixClient.NotSucceeded.Contains(status.Value)
&& monitorId != null)
{
count += await client.RetryFailedAsync(monitorId);
}
}
return quot;transfer failed - retried {count} files";
}
if (summary.Received == 0) return "nothing received - ask the partner";
return "ok";
}| 구분 | 증상 | 대응 |
|---|---|---|
| 전송 실패 | 전송 이력에 실패 상태 | 실패한 파일 재전송 |
| 미수신 | 전송 이력 자체가 없음 | 협력사에 확인 요청 |
| 연결 끊김 | isConnected가 거짓 |
연결 복구 후 밀린 파일 수집 |
| 확인 항목 | 확인 내용 |
|---|---|
| 송신 | 내부에서 협력사로 보낸 전송 |
| 수신 | 감시 자동화로 가져온 전송 |
| 도착 경로 | 협력사 식별자로 구분된 저장 위치 |
| 상태 | 전송 상태와 성공 여부 |
| 연결 | 협력사 장비의 연결 상태 |