File: /var/www/test/Installationlist/fm_integration/fm_helper.php
<?php
// ============================================================
// fm_helper.php ─ FileMaker Data API 輔助類別
// ------------------------------------------------------------
// 用途:將裝機單資料 + PDF 寫入 FileMaker 指定資料庫/版面
// 設計原則:
// 1. 任何步驟失敗都不丟出例外中斷整個流程,而是回傳結構化結果,
// 讓呼叫端(submit.php)自行決定要不要記錄 log、要不要通知。
// 2. 所有設定(主機、帳密、layout、欄位對應)集中在 fm_config.php,
// 方便未來調整不需要動到邏輯本體。
// ============================================================
class FMHelper
{
private string $host; // 例: https://fm.yourdomain.com.tw
private string $database; // 資料庫檔名(不含 .fmp12)
private string $username;
private string $password;
private bool $verifySSL; // 正式環境建議 true;若用自簽憑證測試可設 false
private int $timeout; // 秒
private ?string $token = null;
public function __construct(array $config)
{
$this->host = rtrim($config['host'], '/');
$this->database = $config['database'];
$this->username = $config['username'];
$this->password = $config['password'];
$this->verifySSL = $config['verify_ssl'] ?? true;
$this->timeout = $config['timeout_sec'] ?? 15;
}
// ── 內部:組出 Data API 基本 URL ──────────────────
private function apiBase(): string
{
// FileMaker Data API 版本,目前主流為 vLatest(FMS 19.3+ 支援)
// 若您的 FileMaker Server 版本較舊,可能需要改成固定版本號,例如 v1
return $this->host . '/fmi/data/vLatest/databases/' . rawurlencode($this->database);
}
// ── 內部:統一的 cURL 執行器 ──────────────────────
private function curlRequest(string $method, string $url, $body = null, array $extraHeaders = [], bool $isMultipart = false): array
{
$ch = curl_init($url);
$headers = $extraHeaders;
if (!$isMultipart) {
$headers[] = 'Content-Type: application/json';
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => $this->verifySSL,
CURLOPT_SSL_VERIFYHOST => $this->verifySSL ? 2 : 0,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => 8,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $isMultipart ? $body : json_encode($body, JSON_UNESCAPED_UNICODE));
}
$raw = curl_exec($ch);
$errNo = curl_errno($ch);
$errMsg = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errNo !== 0) {
return ['ok' => false, 'http_code' => 0, 'error' => "cURL錯誤({$errNo}): {$errMsg}", 'data' => null];
}
$json = json_decode($raw, true);
if ($json === null && $raw !== '') {
return ['ok' => false, 'http_code' => $httpCode, 'error' => '回應非JSON:' . substr($raw, 0, 300), 'data' => null];
}
$fmErrorCode = $json['messages'][0]['code'] ?? null;
$fmErrorMsg = $json['messages'][0]['message'] ?? '';
$ok = ($httpCode >= 200 && $httpCode < 300) && ($fmErrorCode === '0' || $fmErrorCode === null);
return [
'ok' => $ok,
'http_code' => $httpCode,
'error' => $ok ? null : "FM錯誤 code={$fmErrorCode} msg={$fmErrorMsg}",
'data' => $json,
];
}
// ── 1. 登入取得 token ─────────────────────────────
public function login(): array
{
$url = $this->apiBase() . '/sessions';
$authHeader = 'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password);
$res = $this->curlRequest('POST', $url, new stdClass(), [$authHeader]);
if ($res['ok']) {
$this->token = $res['data']['response']['token'] ?? null;
if (!$this->token) {
return ['ok' => false, 'error' => '登入成功但未取得 token'];
}
return ['ok' => true];
}
return ['ok' => false, 'error' => $res['error'] ?? '登入失敗', 'http_code' => $res['http_code']];
}
// ── 2. 登出(釋放 session)─────────────────────────
public function logout(): void
{
if (!$this->token) return;
$url = $this->apiBase() . '/sessions/' . $this->token;
$this->curlRequest('DELETE', $url, null, []);
$this->token = null;
}
private function authHeader(): string
{
return 'Authorization: Bearer ' . $this->token;
}
// ── 3. 用條件查詢記錄(find)────────────────────────
// $layout: 版面名稱
// $query: 例如 ['user_id' => '12345'](單一條件即可,FM find 語法用 = 開頭代表完全比對)
public function findRecord(string $layout, array $query): array
{
$url = $this->apiBase() . '/layouts/' . rawurlencode($layout) . '/_find';
// FileMaker find 語法:欄位值前加 "==" 代表完全相符(避免局部比對找錯記錄)
$exactQuery = [];
foreach ($query as $field => $value) {
$exactQuery[$field] = '==' . $value;
}
$body = ['query' => [$exactQuery], 'limit' => 1];
$res = $this->curlRequest('POST', $url, $body, [$this->authHeader()]);
// FM 找不到符合的記錄時,會回傳 code 401(非HTTP狀態碼,是FM自訂錯誤碼),這不算「失敗」,是「查無資料」
if (!$res['ok']) {
$fmCode = $res['data']['messages'][0]['code'] ?? null;
if ($fmCode === '401') {
return ['ok' => true, 'found' => false, 'record_id' => null];
}
return ['ok' => false, 'found' => false, 'error' => $res['error']];
}
$recordId = $res['data']['response']['data'][0]['recordId'] ?? null;
return ['ok' => true, 'found' => $recordId !== null, 'record_id' => $recordId];
}
// ── 4. 新增記錄 ───────────────────────────────────
// $fieldData: 關聯陣列,key 為 FileMaker 欄位名稱
public function createRecord(string $layout, array $fieldData): array
{
$url = $this->apiBase() . '/layouts/' . rawurlencode($layout) . '/records';
$res = $this->curlRequest('POST', $url, ['fieldData' => $fieldData], [$this->authHeader()]);
if (!$res['ok']) {
return ['ok' => false, 'error' => $res['error']];
}
$recordId = $res['data']['response']['recordId'] ?? null;
return ['ok' => true, 'record_id' => $recordId];
}
// ── 5. 更新既有記錄 ────────────────────────────────
public function updateRecord(string $layout, string $recordId, array $fieldData): array
{
$url = $this->apiBase() . '/layouts/' . rawurlencode($layout) . '/records/' . rawurlencode($recordId);
$res = $this->curlRequest('PATCH', $url, ['fieldData' => $fieldData], [$this->authHeader()]);
if (!$res['ok']) {
return ['ok' => false, 'error' => $res['error']];
}
return ['ok' => true];
}
// ── 6. 上傳檔案到容器欄位 ──────────────────────────
// $containerField: 容器欄位名稱(若該欄位是重複欄位,可用 fieldName(repetition) 格式,一般不需要)
public function uploadToContainer(string $layout, string $recordId, string $containerField, string $filePath, string $uploadFilename = ''): array
{
if (!file_exists($filePath)) {
return ['ok' => false, 'error' => "檔案不存在: {$filePath}"];
}
$uploadFilename = $uploadFilename ?: basename($filePath);
$url = $this->apiBase() . '/layouts/' . rawurlencode($layout)
. '/records/' . rawurlencode($recordId)
. '/containers/' . rawurlencode($containerField);
$cfile = new CURLFile($filePath, 'application/pdf', $uploadFilename);
$multipartBody = ['upload' => $cfile];
$res = $this->curlRequest('POST', $url, $multipartBody, [$this->authHeader()], true);
if (!$res['ok']) {
return ['ok' => false, 'error' => $res['error']];
}
return ['ok' => true];
}
// ── 整合方法:find-or-create,再寫入欄位+容器 ────────
// 這是 submit.php 會呼叫的主要進入點
public function upsertWithPdf(
string $layout,
string $matchField,
string $matchValue,
array $fieldData,
?string $pdfPath = null,
string $containerField = ''
): array {
$loginRes = $this->login();
if (!$loginRes['ok']) {
return ['ok' => false, 'step' => 'login', 'error' => $loginRes['error']];
}
try {
// 1) 查詢是否已有該 user_id 的記錄
$findRes = $this->findRecord($layout, [$matchField => $matchValue]);
if (!$findRes['ok']) {
return ['ok' => false, 'step' => 'find', 'error' => $findRes['error']];
}
if ($findRes['found']) {
// 2a) 更新既有記錄
$recordId = $findRes['record_id'];
$writeRes = $this->updateRecord($layout, $recordId, $fieldData);
if (!$writeRes['ok']) {
return ['ok' => false, 'step' => 'update', 'error' => $writeRes['error']];
}
$action = 'updated';
} else {
// 2b) 新增記錄(記得把 matchField 也放進 fieldData,否則新記錄會找不到這個值)
$fieldData[$matchField] = $matchValue;
$createRes = $this->createRecord($layout, $fieldData);
if (!$createRes['ok']) {
return ['ok' => false, 'step' => 'create', 'error' => $createRes['error']];
}
$recordId = $createRes['record_id'];
$action = 'created';
}
// 3) 上傳 PDF 到容器欄位(若有提供)
if ($pdfPath && $containerField) {
$uploadRes = $this->uploadToContainer($layout, $recordId, $containerField, $pdfPath);
if (!$uploadRes['ok']) {
// PDF上傳失敗不影響欄位寫入已成功的事實,但要回報
return ['ok' => false, 'step' => 'upload_pdf', 'error' => $uploadRes['error'],
'record_id' => $recordId, 'action' => $action, 'fields_written' => true];
}
}
return ['ok' => true, 'record_id' => $recordId, 'action' => $action];
} finally {
// 無論成功或失敗,一定要登出釋放 session(FileMaker 同時session數有限制)
$this->logout();
}
}
}