kintone
投稿日:

「生徒の成績、Excel職人の先生に頼っていませんか?」── kintoneで成績管理と通知表作成を効率化する

皆さんこんにちは。kintoneアプリエンジニアのtomiokaです。

先日、私立高校の学年主任の方から「成績処理を特定の先生のExcelに頼りきっていて、その先生が来年度に異動する可能性がある」と相談がありました。

生徒数500名ほどの高校で、成績処理は数学科のベテランの先生が15年ほど前に作ったExcelマクロで回っている。教科担任が点数を入力すると、評定の計算、クラス別の成績一覧、通知表用のデータ出力まで全部やってくれる。問題は、そのマクロを理解できる先生が本人しかいないことです。「一度パソコンが壊れてファイルが飛んだことがあって、USBのバックアップから復旧するまで3日間、学校中が大騒ぎになりました」と。

この記事は、生徒数300〜800名程度の私立高校で、成績処理が特定の教員のExcelスキルに依存している学校の学年主任や事務職員の方に向けて書いています。

Excel依存の成績管理で何が起きていたか

詳しく聞いてみると、問題は「属人化」だけではありませんでした。

まず、入力の混乱です。学期末になると、各教科担任が点数をExcelファイルに入力するのですが、ファイルの受け渡しがメールやUSBメモリ。同じファイルを2人の先生が別々に編集してしまい、どちらが正しいかわからなくなることがあったそうです。

次に、権限の問題です。Excelファイルには全校生徒の全教科の点数が入っています。つまり、数学の先生でも英語の点数が見えてしまう。個人情報保護の観点から問題があるのですが、「Excelだからしょうがない」で済ませてきたそうです。

そして、成績推移の追跡です。学期ごとにファイルが別になるため、ある生徒の1年間の成績推移を見るには、3つのファイルを開いて見比べる必要がある。三者面談の前にこの作業をクラス全員分やるのは、正直なところ現実的ではないですよね。

kintoneで構築した3つのアプリ

生徒マスタアプリ

出欠管理と同様に、生徒の基本情報を一元管理するアプリです。すでに出欠管理でkintoneを使っている学校であれば、同じ生徒マスタを共有できます。

フィールド名フィールドタイプ用途
生徒番号文字列(1行)一意の識別子
氏名文字列(1行)生徒のフルネーム
学年ドロップダウン1年/2年/3年
クラスドロップダウン1組〜8組
担任名文字列(1行)クラス担任の氏名
入学年度数値成績の年度管理に使用

成績入力アプリ

教科担任が自分の担当教科の点数を入力するアプリです。

フィールド名フィールドタイプ用途
年度ドロップダウン2025/2026/2027
学期ドロップダウン1学期/2学期/3学期
生徒番号ルックアップ生徒マスタから取得
氏名文字列(1行)ルックアップで自動コピー
学年・クラス文字列(1行)ルックアップで自動コピー
教科ドロップダウン国語/数学/英語/理科/社会/…
中間テスト数値100点満点
期末テスト数値100点満点
平常点数値各校の基準による
合計点計算自動計算
評定数値5段階(後述のカスタマイズで自動算出)
入力者作成者誰が入力したかの記録

ポイントは、1レコードが「1人の生徒 × 1教科 × 1学期」になっている点です。Excelのように1枚のシートに全教科を横に並べるのではなく、縦にレコードを積んでいく設計です。

この設計には理由があります。教科ごとに別レコードにすることで、アクセス権限を教科単位で制御できるからです。数学の先生は数学のレコードしか見えない、という制御がkintoneの標準機能で実現できます。

通知表集計アプリ

学期末に、成績入力アプリのレコードを生徒ごとに集約して通知表用のデータを生成するアプリです。

フィールド名フィールドタイプ用途
年度ドロップダウン対象年度
学期ドロップダウン対象学期
生徒番号ルックアップ生徒マスタから取得
氏名文字列(1行)ルックアップで自動コピー
学年・クラス文字列(1行)ルックアップで自動コピー
成績一覧テーブル教科・中間・期末・平常点・合計・評定を1行ずつ
評定合計数値全教科の評定合計
評定平均数値評定合計÷教科数
出力ステータスドロップダウン未出力/出力済

このアプリのレコードは手入力ではなく、後述のカスタマイズで成績入力アプリから自動生成します。

評定を自動算出するカスタマイズ

成績入力アプリで点数を入力して保存するとき、合計点に応じて5段階の評定を自動で算出するカスタマイズです。

(function() {
  'use strict';

  // 評定の基準(学校ごとに調整)
  var GRADE_THRESHOLDS = [
    { min: 80, grade: 5 },
    { min: 65, grade: 4 },
    { min: 50, grade: 3 },
    { min: 35, grade: 2 },
    { min: 0, grade: 1 }
  ];

  function calculateGrade(total) {
    for (var i = 0; i < GRADE_THRESHOLDS.length; i++) {
      if (total >= GRADE_THRESHOLDS[i].min) {
        return GRADE_THRESHOLDS[i].grade;
      }
    }
    return 1;
  }

  // 追加画面・編集画面の保存前イベント
  var events = [
    'app.record.create.submit',
    'app.record.edit.submit'
  ];

  kintone.events.on(events, function(event) {
    var record = event.record;

    var midterm = Number(record['中間テスト'].value) || 0;
    var final_exam = Number(record['期末テスト'].value) || 0;
    var daily = Number(record['平常点'].value) || 0;

    var total = midterm + final_exam + daily;
    record['評定'].value = calculateGrade(total);

    return event;
  });
})();

処理の流れを整理すると、以下のようになります。

  • 追加画面・編集画面の保存前イベント(app.record.create.submit / app.record.edit.submit)でレコードの値を取得する
  • 中間テスト・期末テスト・平常点の合計を計算する
  • 合計点を評定基準テーブル(GRADE_THRESHOLDS)と照合し、5段階の評定を自動セットする

評定の基準は学校ごとに異なるため、GRADE_THRESHOLDS の数値を変更するだけで調整できるようにしています。

通知表データを一括生成する

学期末に、成績入力アプリの全レコードを生徒ごとにまとめて通知表集計アプリに登録するカスタマイズです。教務主任がボタンを押すと、対象学期の成績データを自動で集約します。

(function() {
  'use strict';

  var GRADES_APP_ID = 86;   // 成績入力アプリのID
  var REPORT_APP_ID = 87;   // 通知表集計アプリのID

  kintone.events.on('app.record.index.show', function(event) {
    if (document.getElementById('generate-report-btn')) {
      return event;
    }

    var headerSpace = kintone.app.getHeaderMenuSpaceElement();
    if (!headerSpace) {
      return event;
    }

    var btn = document.createElement('button');
    btn.id = 'generate-report-btn';
    btn.textContent = '通知表データ一括生成';
    btn.style.cssText = 'padding: 8px 16px; background: #27ae60; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;';
    headerSpace.appendChild(btn);

    btn.addEventListener('click', function() {
      var year = prompt('年度を入力してください(例: 2026)');
      var term = prompt('学期を入力してください(例: 1学期)');
      if (!year || !term) {
        return;
      }

      btn.disabled = true;
      btn.textContent = '生成中...';

      var query = '年度 in ("' + year + '") and 学期 in ("' + term + '")';
      fetchAllRecords(GRADES_APP_ID, query)
        .then(function(records) {
          // 生徒ごとにグループ化
          var grouped = {};
          for (var i = 0; i < records.length; i++) {
            var studentId = records[i]['生徒番号'].value;
            if (!grouped[studentId]) {
              grouped[studentId] = {
                name: records[i]['氏名'].value,
                cls: records[i]['学年・クラス'].value,
                subjects: []
              };
            }
            grouped[studentId].subjects.push({
              subject: records[i]['教科'].value,
              midterm: records[i]['中間テスト'].value,
              final_exam: records[i]['期末テスト'].value,
              daily: records[i]['平常点'].value,
              total: records[i]['合計点'].value,
              grade: records[i]['評定'].value
            });
          }

          // 通知表集計レコードを作成
          var postRecords = [];
          var ids = Object.keys(grouped);
          for (var j = 0; j < ids.length; j++) {
            var student = grouped[ids[j]];
            var gradeSum = 0;
            var tableRows = [];

            for (var k = 0; k < student.subjects.length; k++) {
              var s = student.subjects[k];
              gradeSum += Number(s.grade);
              tableRows.push({
                value: {
                  '教科名': { value: s.subject },
                  '中間点': { value: s.midterm },
                  '期末点': { value: s.final_exam },
                  '平常点数': { value: s.daily },
                  '合計点数': { value: s.total },
                  '評定値': { value: s.grade }
                }
              });
            }

            postRecords.push({
              '年度': { value: year },
              '学期': { value: term },
              '生徒番号': { value: ids[j] },
              '成績一覧': { value: tableRows },
              '評定合計': { value: gradeSum },
              '評定平均': { value: Math.round(gradeSum / student.subjects.length * 10) / 10 },
              '出力ステータス': { value: '未出力' }
            });
          }

          // 100件ずつバッチ登録
          return batchPost(REPORT_APP_ID, postRecords);
        })
        .then(function(count) {
          alert(count + '件の通知表データを生成しました。');
          btn.disabled = false;
          btn.textContent = '通知表データ一括生成';
        })
        .catch(function(error) {
          console.error(error);
          alert('生成中にエラーが発生しました: ' + error.message);
          btn.disabled = false;
          btn.textContent = '通知表データ一括生成';
        });
    });

    return event;
  });

  function fetchAllRecords(appId, query, opt_offset, opt_records) {
    var offset = opt_offset || 0;
    var records = opt_records || [];
    var body = {
      app: appId,
      query: query + ' limit 500 offset ' + offset
    };

    return kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', body)
      .then(function(resp) {
        records = records.concat(resp.records);
        if (resp.records.length === 500) {
          return fetchAllRecords(appId, query, offset + 500, records);
        }
        return records;
      });
  }

  function batchPost(appId, records) {
    var batches = [];
    for (var i = 0; i < records.length; i += 100) {
      batches.push(records.slice(i, i + 100));
    }

    var total = 0;
    return batches.reduce(function(promise, batch) {
      return promise.then(function() {
        var body = {
          app: appId,
          records: batch
        };
        return kintone.api(kintone.api.url('/k/v1/records.json', true), 'POST', body)
          .then(function() {
            total += batch.length;
          });
      });
    }, Promise.resolve()).then(function() {
      return total;
    });
  }
})();

処理が長いですが、やっていることはシンプルです。

  • 成績入力アプリから指定した年度・学期のレコードを全件取得する
  • 生徒番号でグループ化して、各生徒の全教科の成績をまとめる
  • 通知表集計アプリにレコードとして一括登録する(100件ずつバッチ処理)

500名の生徒で10教科あれば5,000件のレコードを処理することになるため、REST APIの500件制限を回避する fetchAllRecords 関数と、登録を100件ずつ分割する batchPost 関数を使っています。

アクセス権限の設計

成績データは最も機密性の高い個人情報です。権限設計は出欠管理以上に慎重に行いました。

役割生徒マスタ成績入力通知表集計
管理職閲覧・編集全教科閲覧閲覧・編集
教務主任閲覧・編集全教科閲覧・編集閲覧・編集
教科担任閲覧自教科のみ閲覧・編集アクセス不可
クラス担任閲覧自クラスのみ閲覧自クラスのみ閲覧
事務職員閲覧アクセス不可閲覧(出力用)

最も重要なのは、教科担任は自分の教科のレコードしか編集できないという制約です。kintoneのレコード単位のアクセス権限で、「教科」フィールドの値と教科担任のグループの組み合わせで制御しています。

クラス担任は全教科の成績を閲覧できますが、自分のクラスの生徒に限定されます。三者面談の準備で全教科の成績を確認する必要があるためです。

ハマりどころ

テーブルフィールドのREST API登録

通知表集計アプリの「成績一覧」テーブルにREST APIでデータを登録するとき、テーブル行のJSONフォーマットがやや複雑です、、、、各行を { value: { フィールドコード: { value: 値 } } } の形で渡す必要があります。

最初は { フィールドコード: 値 } で渡してしまい、何度やってもエラーになりました。公式ドキュメントのテーブル登録のサンプルを読み直して解決しましたが、テーブルのネスト構造はAPI経由で操作するときに特にハマりやすいポイントです。

評定基準の学校間差異

5段階評定の基準は、学校ごと・教科ごとに異なります。今回は全教科共通の合計点ベースでシンプルに実装しましたが、実際には「中間テストの配点比率が教科によって異なる」「実技教科は平常点の比重が大きい」といった要件が出てきます。

対応として、教科ごとの配点比率をマスタアプリで管理し、評定計算時に参照する方式も検討しましたが、まずは「Excel職人からの脱却」が最優先だったため、シンプルな構造で運用を開始し、教科ごとの調整は次フェーズにしました。

年度またぎのデータ管理

学年が変わると生徒のクラスが変わります。成績データは入力時点の学年・クラスで記録されるため、過年度のデータを検索するときに「現在のクラス」ではなく「当時のクラス」で表示されます。

これは正しい動作ですが、教員から「去年のデータなのに今のクラスで検索したい」という要望が出ました。対応として、生徒番号を軸にした検索ビューを用意し、クラスに依存しない検索ができるようにしました。

導入結果

運用開始から1学期分が経過した時点での変化です。

  • 成績入力の効率: 教科担任がkintoneに直接入力するため、Excelファイルの受け渡しがなくなった。ファイルの競合やバージョン違いの事故がゼロに
  • 通知表データの生成: Excel マクロで半日かかっていた集計作業が、ボタン1回で完了するようになった
  • 属人化の解消: Excelマクロを理解できる先生が不在でも、kintoneの設定は教務主任が管理できるようになった。「あの先生がいないと成績処理できない」状態を脱した
  • 権限管理: 教科担任は自分の教科だけ、クラス担任は自分のクラスだけ、という適切なアクセス制御が実現した

学年主任の方からは「一番助かったのは、三者面談の前にクラス全員の全教科の成績を一覧で確認できるようになったこと。以前はExcelを3ファイル開いて見比べていたのが、kintoneの絞り込みで1画面で見られる」との声をもらいました。

まとめ

今回は、kintoneを使った学校の成績管理と通知表データの自動生成について紹介しました。

成績管理のkintone化で最も大きかったのは、「属人化の解消」と「権限管理の実現」です。Excel マクロは便利ですが、作った人しか直せない、全データが丸見えになる、という2つの問題を構造的に抱えています。kintoneはこの2つを標準機能で解決できます。

押さえておきたいポイントは以下の3つです。

  • 1レコード=1生徒×1教科×1学期の設計にして、教科単位のアクセス権限を実現する
  • 評定の自動算出はシンプルな基準からスタートし、教科ごとの調整は運用が安定してから行う
  • REST APIの500件制限とテーブルフィールドのJSON構造は、実装前に公式ドキュメントで確認しておく

学校の成績管理や校務DXについて相談したい方は、株式会社ファストコーディングでkintoneのカスタマイズや業務アプリの構築を承っていますので、お問い合わせフォームからお気軽にご連絡ください。