予実.app

運営マニュアル

運営者向けの運用ガイドです。初期設定から日次運用まで、必要な手順を順番にまとめています。

最短4ステップ:1) GAS接続 → 2) マスタ設定 → 3) 目標/販管費 → 4) 案件入力
Cloudflare版(会社専用URL 〜.pages.dev)をお使いの方へ:ログインして使うだけです。2章の「GAS接続」と設定画面の「GAS URL」「APIキー」は不要・操作禁止です。詳しくは 9. Cloudflare版の運用 を読んでください。
運用は必ずGoogle Workspaceで行ってください。
閲覧権限・編集権限をユーザー単位で厳密に管理でき、誤共有や情報漏えいのリスクを大きく下げられるためです。
補足:本アプリは全般的にAI機能を組み込んでいません。案件入力・集計・可視化をシンプルに運用する前提です。

最近のアップデート

  • ● Cloudflare版(ログインして使う企業向け運用)の章を追加し、配布用GASコードをAPIキー検証つきの最新版に差し替えました。 2026/10/03
    Xでポスト
  • ● 主要KPI表示と比較表の文言を整理し、初見ユーザーの理解しやすさを改善しました。 2026/03/30
    Xでポスト
  • ● 初期セットアップ手順(GAS接続→保存→固定化)を見直し、運用開始までの手順をさらに分かりやすくしました。 2026/03/29
    Xでポスト
  • ● 導入手順(GAS接続→保存→固定化)を再確認し、運用開始までの迷いを減らす構成へ更新しました。 2026/03/29
    Xでポスト

1. 事前準備(3点)

2. GAS接続(最重要)

ここがつながれば運用準備の8割完了です。Cloudflare版の方はこの章は不要です(9章へ)。
  1. スプレッドシートを作成
  2. APIキーを1つ決める(推測されにくい英数字。例: abc-5f3a…)
  3. GASの Code.gs に下記コードを貼り付け、先頭の VALID_KEY を決めたキーに書き換える
  4. Webアプリとしてデプロイ(次のユーザーとして実行:自分 / アクセスできるユーザー:全員)
  5. 発行URLをアプリ設定の GAS URL に、キーを APIキー 欄に入れて保存
  6. このHTMLに固定 を押す
  7. 接続テスト 実行後、右上 ↻ で同期確認
右上が「● 同期済み」になれば接続完了です。
APIキーのないGASは、URLを知っている人なら誰でもデータを読み書きできます。必ずキーを設定してください。コードを更新するときは「デプロイを管理」→ 既存デプロイの鉛筆 →「新バージョン」で更新します(「新しいデプロイ」はURLが変わるので使わない)。

GAS (.gs) コピペ用

// =====================================================
// 予実.app GAS バックエンド
// =====================================================
// 【重要】VALID_KEY は会社ごとに別の値にする。他社と共用しない。
// 予実.app 設定タブ > APIキー欄 にこのキーを入力すること。
const VALID_KEY = 'ここに決めたAPIキーを入れる';

const DEALS_SHEET_NAME = 'Deals';
const CONFIG_SHEET_NAME = 'Config';

function validateKey(e) {
  return ((e && e.parameter && e.parameter.key) || '') === VALID_KEY;
}

// 案件シートに必要な見出し。足りないものは initSheets が右端に足す
const DEAL_HEADERS = ['id', 'year', 'month', 'cat', 'st', 'tanto', 'name', 'uri', 'gen', 'note', 'genYear', 'genMonth', 'startYear', 'startMonth', 'kind'];

function initSheets() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let dealSheet = ss.getSheetByName(DEALS_SHEET_NAME);
  if (!dealSheet) {
    dealSheet = ss.insertSheet(DEALS_SHEET_NAME);
    dealSheet.appendRow(DEAL_HEADERS);
  } else {
    // 10列しかない古いシートでも、原価計上月・開始月・区分が保存されるよう見出しを揃える
    const lastCol = Math.max(dealSheet.getLastColumn(), 1);
    const headers = dealSheet.getRange(1, 1, 1, lastCol).getValues()[0].map(String);
    const missing = DEAL_HEADERS.filter(h => headers.indexOf(h) === -1);
    if (missing.length) {
      dealSheet.getRange(1, lastCol + 1, 1, missing.length).setValues([missing]);
    }
  }
  if (!ss.getSheetByName(CONFIG_SHEET_NAME)) {
    const s = ss.insertSheet(CONFIG_SHEET_NAME);
    s.appendRow(['key', 'value']);
  }
}

function doGet(e) {
  if (!validateKey(e)) {
    return ContentService
      .createTextOutput(JSON.stringify({ ok: false, error: 'Unauthorized' }))
      .setMimeType(ContentService.MimeType.JSON);
  }
  try {
    initSheets();
    const ss = SpreadsheetApp.getActiveSpreadsheet();

    const dealSheet = ss.getSheetByName(DEALS_SHEET_NAME);
    const dealsData = dealSheet.getDataRange().getValues();
    const headers = dealsData[0];
    const deals = [];
    for (let i = 1; i < dealsData.length; i++) {
      if (!dealsData[i][0]) continue;
      let obj = {};
      for (let j = 0; j < headers.length; j++) {
        obj[headers[j]] = dealsData[i][j];
      }
      deals.push(obj);
    }

    const configSheet = ss.getSheetByName(CONFIG_SHEET_NAME);
    const configData = configSheet.getDataRange().getValues();
    let config = {};
    for (let i = 1; i < configData.length; i++) {
      const key = configData[i][0];
      const val = configData[i][1];
      if (!key) continue;
      try {
        config[key] = JSON.parse(val);
      } catch(err) {
        config[key] = val;
      }
    }

    return ContentService
      .createTextOutput(JSON.stringify({ ok: true, deals: deals, config: config }))
      .setMimeType(ContentService.MimeType.JSON);

  } catch(err) {
    return ContentService
      .createTextOutput(JSON.stringify({ ok: false, error: err.message }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

function doPost(e) {
  if (!validateKey(e)) {
    return ContentService
      .createTextOutput(JSON.stringify({ ok: false, error: 'Unauthorized' }))
      .setMimeType(ContentService.MimeType.JSON);
  }
  try {
    initSheets();
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    let data;
    try {
      data = JSON.parse(e.postData.contents);
    } catch(err) {
      return ContentService
        .createTextOutput(JSON.stringify({ ok: false, error: 'Invalid JSON' }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    if (data.action === 'update_config') {
      const sheet = ss.getSheetByName(CONFIG_SHEET_NAME);
      const rows = sheet.getDataRange().getValues();
      let found = false;
      for (let i = 1; i < rows.length; i++) {
        if (rows[i][0] === data.key) {
          sheet.getRange(i + 1, 2).setValue(JSON.stringify(data.value));
          found = true;
          break;
        }
      }
      if (!found) sheet.appendRow([data.key, JSON.stringify(data.value)]);
      SpreadsheetApp.flush();
      return ContentService
        .createTextOutput(JSON.stringify({ ok: true }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    // 同時に届いた保存が行を取り合わないよう、案件の書き込みは1件ずつ行う
    const lock = LockService.getScriptLock();
    lock.waitLock(10000);
    try {
    const dealSheet = ss.getSheetByName(DEALS_SHEET_NAME);
    // 見出し(1行目)に合わせて書き込む。見出しの無い項目は書かない
    const dealHeaders = dealSheet.getRange(1, 1, 1, dealSheet.getLastColumn()).getValues()[0];
    const rowFromDeal = (d) => dealHeaders.map(h => d[h] !== undefined && d[h] !== null ? d[h] : '');
    const findRow = (id) => {
      const rows = dealSheet.getDataRange().getValues();
      for (let i = 1; i < rows.length; i++) {
        if (String(rows[i][0]) === String(id)) return i + 1;
      }
      return 0;
    };

    if (data.action === 'create' || data.action === 'update') {
      const d = data.deal;
      const row = findRow(d.id);
      if (row) {
        // 再送で同じ案件の create が届いた場合も、行を増やさず上書きする
        dealSheet.getRange(row, 1, 1, dealHeaders.length).setValues([rowFromDeal(d)]);
      } else if (data.action === 'create') {
        dealSheet.appendRow(rowFromDeal(d));
      }
      SpreadsheetApp.flush();
    }
    else if (data.action === 'delete') {
      const rows = dealSheet.getDataRange().getValues();
      for (let i = 1; i < rows.length; i++) {
        if (String(rows[i][0]) === String(data.id)) {
          dealSheet.deleteRow(i + 1);
          break;
        }
      }
      SpreadsheetApp.flush();
    }
    else {
      return ContentService
        .createTextOutput(JSON.stringify({ ok: false, error: 'Unknown action: ' + data.action }))
        .setMimeType(ContentService.MimeType.JSON);
    }
    } finally {
      lock.releaseLock();
    }

    return ContentService
      .createTextOutput(JSON.stringify({ ok: true }))
      .setMimeType(ContentService.MimeType.JSON);

  } catch(err) {
    return ContentService
      .createTextOutput(JSON.stringify({ ok: false, error: err.message }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}
以前のコードを使っている場合は、このコードに貼り替えて「デプロイを管理」から新しいバージョンで再デプロイしてください。案件シートに足りない列(原価月・開始月・区分)は自動で追加され、既存のデータはそのまま残ります。

3. マスタ設定

最初に整えると、後の入力ミスを大きく減らせます。
カテゴリ/ステータス名を変更すると既存案件の移行が必要な場合があります。

4. 目標と販管費

まずはざっくりでOK。運用しながら精度を上げます。
営業利益は「粗利 - 販管費」で計算されます。

5. 案件入力(日次運用)

  1. + 案件追加 をクリック
  2. 案件名・カテゴリ・担当・ステータス・計上月を入力
  3. 売上・原価・備考を入力して保存
保存後、ダッシュボードへ即時反映されます。

6. 課題入力

7. よくあるエラー

困ったら「URL」「権限」「同期」の3点を先に確認してください。
症状原因対応
お試しモードのままGAS URL未設定 / 権限不足URLとデプロイ権限を確認
同期エラーGAS停止 / CORS / タイムアウトGAS再デプロイ・URL再確認
Access denied許可条件(メール/APIキー)不一致Code.gsとHTML設定を合わせる
数字不一致計上月/ステータス混在フィルタ条件を確認

8. 運用ルール(推奨)

運用前チェック - 右上が「● 同期済み」 - (Cloudflare版)URLを開くとログイン画面が出て、ログインしないと中身が見えない - 会社名 / カテゴリ / 担当が正しい - 案件入力がダッシュボードに反映される - 目標・販管費が入力済み

9. Cloudflare版の運用(企業向け)

会社ごとの専用URL(https://〜.pages.dev/)で使う形です。画面やデータの持ち方は通常版と同じで、違うのは「入口」と「接続のしくみ」だけです。

しくみ

使う人がやること・やらないこと

管理者がやること

期が変わるときの手順

通常版も同じ手順です。アプリは期初〜期末の12か月を表示するだけで、前期の案件はスプレッドシートに残ります。ただし「事業別の目標」「KPI・方針ボード」は1組しか持てず、書き換えると前期の内容は消えます。先に控えを取ってから切り替えてください。
  1. 期末の数字が固まるまで待つ(切り替えると前期の損益は画面から消えます)
  2. スプレッドシートで Deals タブを右クリック →「コピーを作成」→ 名前を Deals_FY10 のように変える
  3. Config タブも同じくコピーして Config_FY10 にする(目標・販管費・KPIボード・期初期末はここに入っています)
  4. 控えのタブは色を変えておく。Deals / Config に戻したり中身を編集したりしない(アプリはこの2つの名前のタブしか読みません)
  5. アプリの「設定・マスタ」で期初・期末を次の期に変えて保存(例:2027-07 〜 2028-06。必ず12か月にする)
  6. 「事業別 目標設定」に新しい期の目標を入れる
  7. 販管費を新しい月に入れる。KPI・方針ボードを新しい期の内容に書き換える
  8. 右上が「● 同期済み」で、ダッシュボードが新しい期の月並びになっていることを確認

困ったとき

専用URLを社外の人に教えても、許可されたメールアドレスがなければ開けません。ただし、スプレッドシート本体の共有設定は別の入口なので、そちらの共有相手も最小限にしてください。