当番の曜日を公平に決めるGASを書きたかった

ハロー、しもやぎです。

今回は珍しく技術の話です。

いや、技術の「ぎj」くらいのレベルの話です。

仕事で「メンバーの当番曜日を公平に決めたいけど、メンバーが4人しかいないから固定制にできない」という状態になったので、それを解決する当番決めGASを書きたかったんですが、結論あんまり上手くいきませんでした。

一応誰かのエンライトメントになる可能性は秘めてるので備忘として残します。オッスオッス。

要件

・メンバーは全部で4人

・1日に2人アサインする

・1人あたり週に2回もしくは3回アサインされる

・GAS実行した日を起点に1か月単位でスケジュール出してみて、累計のアサイン回数が均等になるようにしたい

・可能な限り各メンバーの希望の曜日は叶えてあげたい

・可能な限り「前の週に3回アサインされた人」が「翌週も連続して3回アサインされる」のは避けたい

・結果はスプレッドシートに出力する

・GASからAPI叩くのはNGなのでハードコーディングにする(祝日一覧はスクリプトに埋め込んだ)

実現できなかった部分

・可能な限り「前の週に3回アサインされた人」が「翌週も連続して3回アサインされる」のは避けたかったけど、どうもダメでした。

・ランダムではなくアルゴリズムで決まっているので、パターンが固定化されてしまう(Aさんが2週連続で3回アサインされやすい、Bさんが他の人より希望が通りにくい、など)。

・その他、冗長な部分、芯食ってない部分が多数ありそう。

ソース全文

function assignDuty() {
    // メンバー一覧を定義
    const member = ["トリケラ", "ティラノ", "三葉虫", "プテラノ"];
  
    // 各メンバーが希望する担当曜日を定義
    const desiredDays = {
      "月": ["ティラノ"],
      "火": ["トリケラ", "ティラノ"],
      "水": ["トリケラ", "三葉虫"],
      "木": ["プテラノ"],
      "金": ["プテラノ", "三葉虫"],
    };
  
    // 各メンバーの希望する担当曜日を数値で表現するためのオブジェクトを初期化
    const weights = {};
    for (let i = 0; i < member.length; i++) {
      weights[member[i]] = {"月": 0, "火": 0, "水": 0, "木": 0, "金": 0};
    }
    // 各メンバーの希望する担当曜日に対する重み付けを設定
    for (const day in desiredDays) {
      const desiredMember = desiredDays[day];
      for (let i = 0; i < desiredMember.length; i++) {
        weights[desiredMember[i]][day] = 1;  // 希望曜日に対する重み付けを設定
      }
    }
  
    // 日本の祝日一覧
    const holidays = {
        "2024/1/1": "元日",
        "2024/1/8": "成人の日",
        "2024/2/11": "建国記念の日",
        "2024/2/12": "休日",
        "2024/2/23": "天皇誕生日",
        "2024/3/20": "春分の日",
        "2024/4/29": "昭和の日",
        "2024/5/3": "憲法記念日",
        "2024/5/4": "みどりの日",
        "2024/5/5": "こどもの日",
        "2024/5/6": "休日",
        "2024/7/15": "海の日",
        "2024/8/11": "山の日",
        "2024/8/12": "休日",
        "2024/9/16": "敬老の日",
        "2024/9/22": "秋分の日",
        "2024/9/23": "休日",
        "2024/10/14": "スポーツの日",
        "2024/11/3": "文化の日",
        "2024/11/4": "休日",
        "2024/11/23": "勤労感謝の日",
        "2025/1/1": "元日",
        "2025/1/13": "成人の日",
        "2025/2/11": "建国記念の日",
        "2025/2/23": "天皇誕生日",
        "2025/2/24": "休日",
        "2025/3/20": "春分の日",
        "2025/4/29": "昭和の日",
        "2025/5/3": "憲法記念日",
        "2025/5/4": "みどりの日",
        "2025/5/5": "こどもの日",
        "2025/5/6": "休日",
        "2025/7/21": "海の日",
        "2025/8/11": "山の日",
        "2025/9/15": "敬老の日",
        "2025/9/23": "秋分の日",
        "2025/10/13": "スポーツの日",
        "2025/11/3": "文化の日",
        "2025/11/23": "勤労感謝の日",
        "2025/11/24": "休日"
  };
  
    // 各メンバーの担当回数と希望が通った回数を追跡するためのオブジェクトを初期化
    let assignmentCount = {};  // 各メンバーの担当回数を追跡するオブジェクト
    let desiredCount = {};  // 各メンバーの希望が通った回数を追跡するオブジェクト
    let weeklyAssignmentCount = {};  // 各メンバーの直近1週間の担当回数を追跡するオブジェクト
    let lastWeekAssignmentCount = {};  // 各メンバーの前の週の担当回数を追跡するオブジェクト
  
    // 各メンバーの担当回数を初期化
    for (let i = 0; i < member.length; i++) {
      assignmentCount[member[i]] = 0;
      desiredCount[member[i]] = 0;
      weeklyAssignmentCount[member[i]] = 0;
      lastWeekAssignmentCount[member[i]] = 0;
    }
  
    // 出力用の配列を初期化
    let dateArray = [];
    let dayNameArray = [];
    let chosenMemberArray1 = [];
    let chosenMemberArray2 = [];
  
    // n日間のスケジュールを作成
    for (let i = 0; i <= 30; i++) {
      let today = new Date();
      today.setDate(today.getDate() + i);
      let formattedDate = Utilities.formatDate(today, "GMT", "yyyy/M/d");
  
      // 週末と祝日をスキップ
      if (today.getDay() === 0 || today.getDay() === 6 || holidays[formattedDate]) {
        continue;
      }
  
      // 曜日を取得
      let dayNames = ["日", "月", "火", "水", "木", "金", "土"];
      let dayName = dayNames[today.getDay()];
  
      // 新しい週が始まるたびに、直近のカレンダー週の担当回数をリセット
      if (dayName === "日") {
        for (let j = 0; j < member.length; j++) {
          let name = member[j];
          lastWeekAssignmentCount[name] = weeklyAssignmentCount[name];
          weeklyAssignmentCount[name] = 0;
        }
      }
  
      // 各メンバーの希望曜日を考慮に入れて、その曜日を希望するメンバーを選ぶ
      let availableMember = desiredDays[dayName] ? desiredDays[dayName] : member;
  
      // 利用可能なメンバーが1人しかいない場合、全メンバーから選ぶ
      if (availableMember.length === 1) {
        availableMember = member;
      }
  
      // 担当回数と希望が通った回数が最も少ないメンバーを選ぶ
      availableMember.sort((a, b) => weights[a][dayName] - weights[b][dayName] + assignmentCount[a] - assignmentCount[b] + weeklyAssignmentCount[a] - weeklyAssignmentCount[b] + desiredCount[a] - desiredCount[b]);
      let chosenMember1 = availableMember[0];
      assignmentCount[chosenMember1] += 1;  // 担当回数をインクリメント
      if (weights[chosenMember1][dayName] > 0) {
        desiredCount[chosenMember1] += 1;  // 希望が通った回数をインクリメント
      }
  
      weeklyAssignmentCount[chosenMember1] += 1;  // 直近のカレンダー週の担当回数を追加
  
      // 2人目のメンバーを選ぶ
      availableMember = availableMember.filter(name => name !== chosenMember1);
      availableMember.sort((a, b) => weights[a][dayName] - weights[b][dayName] + assignmentCount[a] - assignmentCount[b] + weeklyAssignmentCount[a] - weeklyAssignmentCount[b] + desiredCount[a] - desiredCount[b]);
      let chosenMember2 = availableMember[0];
      assignmentCount[chosenMember2] += 1;  // 担当回数をインクリメント
      if (weights[chosenMember2][dayName] > 0) {
        desiredCount[chosenMember2] += 1;  // 希望が通った回数をインクリメント
      }
  
      weeklyAssignmentCount[chosenMember2] += 1;  // 直近のカレンダー週の担当回数を追加
  
      // 直近のカレンダー週の担当回数が3回を超える場合、そのメンバーを利用可能なメンバーから除外
      for (let j = 0; j < member.length; j++) {
        let name = member[j];
        if (weeklyAssignmentCount[name] >= 3 || lastWeekAssignmentCount[name] >= 3) {
          availableMember = availableMember.filter(memberName => memberName !== name);
        }
      }
  
      // 日付と曜日、選ばれたメンバーを配列に追加
      dateArray.push(formattedDate);
      dayNameArray.push(dayName);
      chosenMemberArray1.push(chosenMember1);
      chosenMemberArray2.push(chosenMember2);
    }
  
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  // スプレッドシートにスケジュールを出力
  sheet.getRange(1, 1, dateArray.length, 1).setValues(dateArray.map(item => [item]));
  sheet.getRange(1, 2, dayNameArray.length, 1).setValues(dayNameArray.map(item => [item]));
  sheet.getRange(1, 3, chosenMemberArray1.length, 1).setValues(chosenMemberArray1.map(item => [item]));
  sheet.getRange(1, 4, chosenMemberArray2.length, 1).setValues(chosenMemberArray2.map(item => [item]));
  
  }



どういう処理やってるかcopilot君に聞いてみた結果

  1. メンバーとその希望する担当曜日を定義します。メンバーは["トリケラ", "ティラノ", "三葉虫", "プテラノ"]の4人で、それぞれが担当したい曜日をdesiredDaysオブジェクトで定義しています。
  2. 各メンバーの希望する担当曜日に対する重み付けを設定します。これはweightsオブジェクトで行います。希望する曜日には重み付けとして1が設定され、それ以外の曜日は0が設定されます。
  3. 日本の祝日一覧を定義します。これはholidaysオブジェクトで行います。
  4. 各メンバーの担当回数と希望が通った回数を追跡するためのオブジェクトを初期化します。これらはassignmentCountdesiredCountで行います。
  5. 30日間のスケジュールを作成します。週末と祝日はスキップされます。
  6. 各メンバーの希望曜日を考慮に入れて、その曜日を希望するメンバーを選びます。利用可能なメンバーが1人しかいない場合、全メンバーから選びます。
  7. 担当回数と希望が通った回数が最も少ないメンバーを選びます。これはavailableMember.sortで行います。
  8. 選ばれたメンバーの担当回数と希望が通った回数をインクリメントします。
  9. 直近のカレンダー週の担当回数が3回を超える場合、そのメンバーを利用可能なメンバーから除外します。
  10. 最後に、日付と曜日、選ばれたメンバーを配列に追加し、スプレッドシートにスケジュールを出力します。

以上がこのコードの処理の流れです。このシステムは、メンバーの希望を最大限に尊重しつつ、公平性を保つためのスケジューリングを行います。また、週末や祝日はスキップするように設定されています。

実行結果

実行結果はスプレッドシートのA~D列に出力されます。

03/21トリケラティラノ
03/22プテラノ三葉虫
03/25トリケラティラノ
03/26トリケラティラノ
03/27三葉虫トリケラ
03/28プテラノ三葉虫
03/29プテラノ三葉虫
04/01プテラノティラノ
04/02トリケラティラノ
04/03三葉虫トリケラ
04/04プテラノティラノ
04/05プテラノ三葉虫
04/08トリケラプテラノ
04/09ティラノトリケラ
04/10三葉虫トリケラ
04/11ティラノプテラノ
04/12三葉虫プテラノ
04/15ティラノ三葉虫
04/16ティラノトリケラ
04/17三葉虫トリケラ
04/18プテラノティラノ
04/19三葉虫プテラノ

それをスプレッドシート内で集計してみるとこんな感じに。

累計希望が通った回数
プテラノ20045119
三葉虫10415119
トリケラ24410118
ティラノ34040117

Good: 累計アサイン回数が均等、概ね希望が通ってる

Bad: このパターンで固定化されるので、チリツモでティラノ君の希望が通る回数がちょっと他の人より少なくなる。

180日単位で出してみるとこんな感じ。

累計アサイン回数は均等化できてるけど、やはりティラノ君がちょっと可哀想ですね。

累計希望が通った回数
プテラノ110025256150
三葉虫30258256150
トリケラ92525106050
ティラノ192501806244


実装方法

  1. スプレッドシートを新規作成して、「拡張機能」から「App Script」を選択
  2. GASが開かれるので、コード.gsの中身を全部消して、↑のコード全文をコピペ。
  3. あと、GAS実行ボタンをスプレッドシート側に追加しなきゃなので、「+」ボタンから「ファイルを追加」⇒「スクリプト」を選んで新しい .gs ファイルを作成。
  4. 新しく作った方の .gsファイルを「menu.gs」に改名して、中身として以下コード全文をコピペ。
function onOpen() {
  let ui = SpreadsheetApp.getUi();           // Uiクラスを取得する
  let menu = ui.createMenu('呼び出し');  // Uiクラスからメニューを作成する
  menu.addItem('GAS実行!', 'assignDuty');   // メニューにアイテムを追加する
  menu.addToUi();                            // メニューをUiクラスに追加する
}

どっちの.gsファイルもCtrl + Sで保存するのを忘れないように。

保存できたら、コード.gsの「assignDuty▼」ってなってる状態にして、「実行」をクリック。問題なければスプレッドシートに出力されるはず。

menu.gsを作ったので、スプレッドシート側のメニューにこういうボタンが増えてるはず。スプレッドシート側からこのボタンで実行できます。



おわり

簡単そうに思えることでもコードで再現しようと思うと難しいんだよな~~~~~~

日々勉強ッス。チッスチッス。

コメント

タイトルとURLをコピーしました