RFID読取率 日次チェック 設定手順書 設定ガイド(図解・シミュレーター)へ

RFID読取率 日次チェック 設定手順書

株式会社デジライズ | 2026年10月版 | 画面の名前は日本語表示の例です。英語名を併記しています。表示が少し違う場合は、英語名でも検索できます。

0この手順書について

集計ファイルに毎日たまる RFID の読取数を毎朝自動でチェックし、仮説に当てはまるものがあれば工場の Teams チャネルに確認を依頼して、回答を記録するまでの設定手順です。上から順に進めれば設定が終わります。

10月16日のお打合せを受けて変えたこと
・フローは一から作らず、設定ファイル(.zip)を取り込む形にしました(4章・5章)。取り込んだ後の設定は、フローの先頭にある4つの値で変えられます(6章)。
・読取率の下限(閾値)を、フローの先頭の値で変えられるようにしました(10章)。
・確認依頼は、工場ごとの Teams チャネルに送ります。チャネル名が工場名のチャネルを、フローが自動で探します(7章)。試験中は、決めた1人のアカウントに送ります(8章)。

システム構成

  1. 集計ファイルはいまの仕組みのまま毎日更新されます。新しい仕組みは、このファイルを読むだけです。
  2. 毎朝、Power Automate のフローが起動し、Office Scripts が集計ファイルを読んで、前日分の確認と仮説の判定をします。読取率の下限(閾値)は、フローの先頭の値で変えられます。
  3. 前日分が入っていなければ、情報システム部に Teams で知らせて終わります。
  4. 当てはまりがあれば、工場ごとに「日次判定結果」へ記録し、チャネル名が工場名の Teams チャネルに確認依頼のカードを送ります(試験中と、チャネルの無い工場は、決めた方に個人あてで送ります)。
  5. チャネルのどなたかがカードで回答すると、その内容と回答者が「実証ログ」に記録されます。
  6. エージェントは、日次判定結果・実証ログ・判定ファイルをナレッジとして参照し、質問に「最新データ/該当した仮説/現場に確認すること」で答えます。
章作業目安の時間使う画面
1始める前の確認5分—
2SharePoint リストを2つ作る20分SharePoint
3スクリプトを置く5分SharePoint
4設定ファイルを作る10分この手順書の画面
5Power Automate に取り込む5分Power Automate(ブラウザー)
6取り込んだ後に変える4つの値5分Power Automate
7工場ごとのチャネルを作る10分Teams
8試験する15分Power Automate・Teams・SharePoint
9本番に切り替える5分Power Automate
10読取率の下限(閾値)を変える2分Power Automate
11エージェントを更新する20分Copilot Studio
12うまくいかないとき・よくあるご質問——

お送りしたファイル

ファイル使う章
日次チェックのスクリプト(.osts)3章(SharePoint の Office Scripts フォルダに置く)
日次チェックのスクリプト(.txt).osts が使えない場合だけ(3章の「別の方法」)。付録Aにも全文があります
エージェント指示文 Ver8(.txt)11章(エージェントの「指示」に貼り替え)
判定ファイル2点(.xlsx)11章(ナレッジ)。前回お送りしたもの(ファイル名の末尾が _20261016)から変更はありません

設定ファイル(.zip)は、御社のサイトやリストの ID が入るため、お送りしていません。4章の画面で作ります。

1始める前の確認5分

フローは1つのアカウントに1本です。パソコンの電源が切れていても、毎朝決めた時刻にクラウドで動きます(クラウド フローはクラウドで格納・実行されます)。担当の方が替わる場合に備えて、取り込んだ後に共同所有者を追加しておくことをおすすめします(12章)。

2SharePoint リストを2つ作る20分

毎朝の判定結果を入れる「日次判定結果」と、拠点の回答を入れる「実証ログ」を、1つのサイトに作ります。工場ごとに分けたいときは、リストの「工場」列で絞り込みます。Teams でリストを見たい場合は、そのチームのサイトに作ってください(2-3)。

2-1 リスト「日次判定結果」を作る

  1. SharePoint サイトを開き、+ 新規 → リスト を選ぶ。
  2. 空白のリスト を選び、名前に 日次判定結果 と入力して 作成 を押す。
  3. + 列の追加 から、下の表の列を1つずつ追加する。列の種類を選び、名前をコピーして貼り付け、保存。
列名種類入る内容(例)
タイトル(最初からある列)—A工場 2026-09-28
工場1 行テキストA工場(当てはまりの無い日は「全工場」)
対象日1 行テキスト2026-09-28
仮説ID1 行テキストP04・P12(当てはまりの無い日は「なし」)
判定内容複数行テキスト根拠の数値と、現場に確認すること
下限1 行テキスト85%(その日の判定に使った読取率の下限)

2-2 リスト「実証ログ」を作る

  1. 2-1 と同じ手順で、名前 実証ログ のリストを作る。
  2. 下の表の列を追加する。
列名種類入る内容(例)
タイトル(最初からある列)—A工場 2026-09-28
工場1 行テキストA工場
対象日1 行テキスト2026-09-28
仮説ID1 行テキストP04
判定内容複数行テキスト確認依頼の内容
確認結果1 行テキスト仮説のとおり/仮説と違う/確認中
確認した内容・原因複数行テキスト前処理のデータ連携が夜間に止まっていた
対応したこと複数行テキスト連携を再実行し、取り込み直した
回答者1 行テキストカードに回答した方の表示名
列名は表のとおりに作り、後から変えないでください。4章の画面と、エージェントの指示文(Ver8)が、この列名で列を探します。
参照列(ルックアップ)は使わないでください。既定のビューに参照列が12を超えるリストは、エージェントのナレッジにできないと Microsoft のドキュメントにあります(表の列はすべてテキストなので、このとおりに作れば当てはまりません)。

2-3 Teams でリストを見る(10月16日のご質問)

SharePoint のリストは、Teams(デスクトップ・Web・モバイル)でチャネルのタブとして開けます(Microsoft Lists アプリ)。

  1. リストを作ったサイトのチームで、チャネルの上の +(タブの追加)を押す。
  2. リスト(Lists)を選んで 保存。
  3. 既存のリストを追加 から「日次判定結果」または「実証ログ」を選ぶ。
同じチームのサイトに作ったリストを追加してください。同じチームのリストなら、Teams の中で一覧・並べ替え・絞り込み・編集ができます。別のサイトのリストをリンクで追加した場合は、表示とコメントはできても編集はできない、と Microsoft のヘルプにあります。モバイルでは表示・編集はできますが、タブの追加はデスクトップか Web で先に行います。リストのアクセス権は SharePoint の設定に従います。

3スクリプトを置く5分

判定のスクリプト(.osts)を、2章のサイトの ドキュメント(既定のドキュメント ライブラリ)の Office Scripts フォルダに置きます。Excel で貼り付けて登録する作業は要りません。

  1. 2章のサイトで ドキュメント を開く。
  2. Office Scripts フォルダが無ければ、+ 新規 → フォルダー で Office Scripts を作る(半角スペースを1つ入れます)。
  3. フォルダを開き、お送りした RFID読取率_日次チェック.osts をドラッグして置く(または アップロード → ファイル)。メールでお送りしたファイルをお使いください。
このスクリプトは集計ファイルを読むだけで、中身は書き換えません。読むのは「工場読取データ (N月)」のシートのうち、対象の月と前の月だけです。当社の環境で、3月〜9月の実データ(2.6MB)を7〜12秒で判定しました(上限は120秒)。
名前を変えないでください。4章で「日次チェック」を名前に含む .osts を探します。
別の方法:集計ファイルを Excel on the web で開き、自動化 → 新しいスクリプト に .txt の全文を貼り付け、名前を付けて保存 でこのフォルダに保存しても同じです(Microsoft が案内している置き方)。

4設定ファイルを作る10分

Power Automate に取り込む設定ファイル(.zip)を、この画面で作ります。御社のサイト・ライブラリ・リスト・スクリプトの ID を入れる必要があるためです(ID が実在しないと取り込めません)。ID は、下の4つのリンクを開いて出た文字を貼るだけで、画面が読み取ります。

入れた値は、この画面の中だけで使います。どこにも送りません。画面を閉じると消えます。

4-1 サイトのアドレスを入れる

2章でリストを作り、3章でスクリプトを置いたサイトを開いたときのアドレスを貼ります。/sites/サイト名 より後ろ(/Lists/… や ? 以降)は自動で外します。

集計ファイルが別のサイトにある場合

4-2 4つのリンクを開き、出た文字を貼る

リンクを押すと、新しいタブに文字(JSON)が出ます。すべて選んでコピーし(Ctrl+A → Ctrl+C。Mac は ⌘A → ⌘C)、下の欄に貼ります。サインインの画面が出たときは、サインインしてからもう一度リンクを押してください。

4-3 運用の値を入れる

集計ファイルの前日分の更新が終わった後の時刻

これを下回ると「低い」とみなします。分析ルール台帳 Ver3 は 85。取り込んだ後も変えられます(10章)

「前日分が未更新」のお知らせと、チャネルが見つからない工場の確認依頼を受け取る方

入っている間は、確認依頼がすべてこの方に届きます。本番に切り替えるときに空欄にします(9章)

4-4 設定ファイルを作る

保存した .zip を、5章で Power Automate に取り込みます。値を間違えた場合は、ここで直して作り直し、取り込み直してください(取り込み直すと新しいフローができるので、前のフローはオフにしてから削除します)。

5Power Automate に取り込む5分

5-1 取り込む

  1. Power Automate を開き、左の マイ フロー → 上の インポート → パッケージのインポート (レガシー) を選ぶ。
  2. アップロード を押し、4章で保存した .zip を選ぶ。「パッケージが正常にアップロードされました。」と出るまで待つ。
  3. 下の 関連リソース に、SharePoint・Excel Online (Business)・Microsoft Teams の3行が出ます。3つとも、右端のスパナのアイコンを押します。
関連リソースの3行。右端のスパナのアイコンを押す
関連リソース:3行とも、右端のスパナを押します(当社の環境の画面)
  1. 右に インポートの設定 が開きます。一覧からご自分のアカウントの接続を選び、保存 を押す。一覧が空のときは + 新規作成 で接続を作り、一覧の更新 を押してから選びます。
インポートの設定で自分の接続を選び、保存を押す
ここで、接続が御社のアカウントに切り替わります
  1. 3つとも接続が入ると、インポート が押せるようになります。押して、しばらく待つ。
3つとも接続が入ったらインポートを押す
3行とも「インポート時に選択する」の下にアカウントが出たら[インポート]
  1. 「すべてのパッケージ リソースが正常にインポートされました。」と出たら、フローを開く を押す。
取り込みが終わったらフローを開く
取り込み完了(当社の環境で 2026年10月16日に確認)
取り込みで「…のインポートに失敗しました」と出たら、エラーの文を12章の表で確認してください。多くは、4章で貼った ID のサイトと、取り込む方のアクセス権の違いです。

5-2 オンにする

取り込んだ直後のフローはオフです。フローの詳細の画面で、上の 有効 のスイッチをオンにします(オフのままでは 実行 もできません)。

試験(8章)の間は、試験の実行が終わるたびにオフに戻してください。試験中は「判定」に過去の日付が入っているため、オンのままだと毎朝その日の確認依頼が送られ、日次判定結果に同じ行がたまります。
フローの詳細画面のオン/オフと実行
オン/オフと[実行]

6取り込んだ後に変える4つの値5分

フローの先頭に、運用で変える値を4つ並べています。4章で入れた値がそのまま入っているので、変えたいときだけ直します。直し方:フローを 編集 で開き、カードを押して 値 の欄を書き換え、右上の 保存。

フローの先頭の4つの値
毎朝の起動の下の4つ(紫のカード)
カードの名前入れる値いつ変えるか
読取率の下限数字(%)。例 85・90閾値を変えるとき(10章)
情シスの通知先メールアドレス(Teams にサインインするアドレス)担当の方が替わるとき
テスト送信先試験する方のメールアドレス。本番は空欄試験のとき(8章)・本番に切り替えるとき(9章)
チームID工場ごとのチャネルがあるチームの ID(4章で自動で入ります)チャネルを別のチームに作るとき
メールアドレスは、Teams にサインインするアドレスを入れてください。別名のアドレスでは届かず、「Graph で電子メールまたは UPN … が含まれるユーザー詳細が見つかりませんでした」というエラーになります(当社の環境で確認)。
毎朝の時刻を変えるとき:一番上の 毎朝の起動 を押し、指定時間 (時) を変えます。タイム ゾーンは東京にしてあります。

7工場ごとのチャネルを作る10分

確認依頼のカードは、チャネル名が工場名と同じチャネルに届きます。フローが毎朝、チームの標準チャネルの一覧から自動で探します。

まずは、8-2 で試す工場のチャネルを1つだけ作ってください。残りの工場のチャネルは、8-2 で届くことを確かめてから作ります(9章)。先にすべて作ると、8-2 の試験で過去の日付の確認依頼が各工場のチャネルに届きます。
  1. 4章の「チームID」のチーム(ふつうはリストを作ったサイトのチーム)で、チャネルを追加(または … → チャネルを作成)を選ぶ。
  2. チャネル名を工場名にする。集計ファイルのブロックの見出しと同じ表記(例 A工場)か、「工場」を除いた名前(例 A)。
  3. 種類は標準にする(プライベートや共有のチャネルには送れません)。
  4. そのチャネルを見る工場の方を、チームのメンバーにする。
チャネルが無い工場の確認依頼は、「情シスの通知先」の方に届きます。チャネルを作るのが後になる工場があっても、取りこぼしません。チャネルの一覧が取れない場合(チームID のグループに Teams のチームが無い など)も、フローは止まらず、確認依頼は個人あて(テスト送信先が空欄なら情シスの通知先)に届きます(当社の環境で、チームの無いグループの ID を入れて確認)。
Microsoft のドキュメントでは、チャネルのカードは、メンバーのどなたかが回答した時点でフローが進みます。カード1枚につき回答は1件です。チャネルでの回答の動きは、当社の環境ではまだ確認していません(8-2 で確かめてください)。

8試験する15分

8-1 1人のアカウントで試す

  1. 6章の「テスト送信先」に、試験する方のアドレスが入っていることを確認する。
  2. フローを 編集 で開き、判定 を押して、targetDate に判定したい過去の日(例 2026-09-28)、expectedDate に -(半角ハイフン)を入れて 保存。
  3. 右上の テスト → 手動 → テスト → フローの実行 で実行する。
  4. Teams の ワークフロー(Workflows)とのチャットに、工場ごとのカードが届くこと、リスト「日次判定結果」に工場ごとの行が入ることを確認する。
  5. カードで結果を選び、回答する を押す。カードが「ご回答ありがとうございました。…」に変わり、リスト「実証ログ」に行が入ることを確認する。
試験の実行結果。判定(スクリプト)は7秒
実行中の画面。回答を待っている間は「実行中」のまま進みます
試したいことtargetDateexpectedDate期待する結果
過去の日を判定する2026-09-28 など-その日に当てはまった工場のカードが、テスト送信先に届く
未更新の通知空欄まだデータがない日(例:明日の日付)判定はせず、情シスの通知先に「…のデータがまだ入っていません。」が届く
当てはまりなし当てはまらない日-カードは届かず、日次判定結果に「全工場/なし」の1行が入る
回答しなかったカードは、フローが最長30日待ちます。試験で回答しないカードが残ったときは、実行の画面の キャンセル で止められます。

8-2 チャネル1つで試す

工場のチャネルに実際に届くことは、当社の環境ではまだ確認できていません(試験用のチームが無いため)。本番の前に、チャネル1つで確かめてください。

  1. 7章で作ったチャネルが、8-1 で当てはまった工場のものであることを確認する(違う場合は、当てはまった工場のチャネルを1つ作る)。
  2. 6章の「テスト送信先」を空欄にして保存し、8-1 と同じ日で実行する。
  3. そのチャネルにカードが届き、ほかの工場のカードは情シスの通知先に届くことを確認する。

9本番に切り替える5分

  1. 残りの工場のチャネルを、7章の手順で作る(8-2 でチャネルに届くことを確かめてから)。
  2. 判定 の targetDate と expectedDate を空欄に戻す。
  3. 「テスト送信先」を空欄にし、「チームID」に値が入っていることを確認する(空欄だと、確認依頼はすべて情シスの通知先に届きます)。
  4. 試験で入った行を、リスト「日次判定結果」と「実証ログ」から削除する(試験の回答を、エージェントが現場で確認した結果として答えないようにするため)。
  5. 「毎朝の起動」の時刻が、集計ファイルの更新が終わった後になっているかを確認する。
  6. 保存 して、フローがオンになっていることを確認する。翌朝から自動で動きます。

10読取率の下限(閾値)を変える2分

フローの 読取率の下限 の値を変えて 保存 します。次の朝の判定から使われます。判定の結果と「日次判定結果」の 下限 の列に、その日に使った値が入ります。

下限を使う判定内容
P06 機器の故障その日、ゲートの8割以上の商品が下限を下回った(前日は8割以上が下限以上)
P01・P03 日ズレ3日前の日のゲート全体の読取率が下限を下回り、読取数が1〜3日後の前処理・仕上の枚数とほぼ一致した
P17(期間の傾向)その商品だけ、期間の中央値が下限を下回っている
下限を上げると、多くの場合は当てはまりが増えます。3月〜9月の実データで試算すると、9月2日〜28日の当てはまりの件数は 85% で140件、90% で152件、確認依頼のカード(1工場1日1枚)は105枚と111枚でした(P01・P03・P06 が増え、P04・P12 は変わりません)。P06 は前日の条件もあるため、下限を上げると外れることもあります。
エージェント(指示文 Ver8)は、日次判定結果の「下限」の列の値で答えます。下限を変えても、指示文を書き換える必要はありません。

空欄、または50〜100以外の数を入れた場合は、85% で判定します(分析ルール台帳 Ver3 の値)。

11Copilot Studio のエージェントを更新する20分

11-1 指示を貼り替える

  1. Copilot Studio でエージェントを開き、概要 の 指示 の 編集 を押す。
  2. 入っている文章をすべて消し、お送りした「エージェント指示文 Ver8」の全文を貼り付けて 保存。

11-2 ナレッジを追加する

  1. ナレッジ → ナレッジの追加 を開く。
  2. SharePoint を選び、2章で作ったリスト「日次判定結果」と「実証ログ」を選んで追加する(画面の表示に従ってサイトとリストを選びます)。
  3. 状態が 準備完了 になるまで待つ。初回は数分〜数十分かかることがあります。
  4. テスト パネルで「最新日に該当した拠点と、現場に確認することを教えて」と聞き、最新データ・当日に該当した仮説・現場に確認すること の3つで答えることを確認する。
反映のタイミング:Microsoft のドキュメントには、SharePoint のリストをナレッジにしたときの反映について、「リアルタイムで最新のデータを使う」という記載と、追加のしかたによっては「4〜6時間ごとの同期」という記載の両方があります。4〜6時間ごとの同期が目安で、それ以上かかることもある前提でお使いください。当日すぐ知りたい内容は Teams の通知で届きます。
リストのナレッジの前提:環境で Dataverse の検索が有効になっている必要があります。照会されるのは各リストの先頭2,048行までです。日次判定結果は1日に数行ずつ増えるため、1年あまりで2,048行に届く見込みです(9月の実データでは1日あたり平均約4行)。届く前に、古い行を別のリストに移してください。また、Dataverse に取り込まれた内容を見るには、利用者に Power Apps または Dynamics 365 の Basic User 以上のライセンスが必要との記載が、Microsoft の公式ドキュメントにあります。

12うまくいかないとき・よくあるご質問

12-1 うまくいかないとき

症状考えられる原因対処
4章で「…がありません」と出るリスト名・列名・フォルダ名が違う/貼った文字が途中で切れている2章・3章の名前を確認し、リンクを開き直してすべて選んでコピーし直す
5章で パッケージのインポート (レガシー) が出ない環境がソリューションでの管理を必須にしている/取り込む権限が無い管理者にご確認ください(取り込みには、その環境の作成者の権限が要ります)
取り込みで「…のインポートに失敗しました」4章の ID と、取り込む方のアクセス権・接続のアカウントが合っていないリストとスクリプトのあるサイトを開ける方の接続を選ぶ。直らない場合はエラーの文をお知らせください
「判定」が 403 エラー集計ファイルへの書き込み権限がない設定する方に、ファイルの編集権限を付ける
「判定」が 502 エラーブックが読み取り専用になっている読み取り専用の設定を外す
実行が失敗し、しばらくして再実行される1回の実行が120秒を超えたスクリプトは読むだけなので、再実行しても結果は同じです。続く場合はご連絡ください
「対象の月のシート…が見つかりませんでした」月のシート名が「工場読取データ (N月)」の形と違う/対象月のシートがまだないシート名と、targetDate の日付を確認する
毎日「まだ入っていません」になる前日分の更新が、フローの実行時刻より後「毎朝の起動」の時刻を遅らせる
「ユーザー詳細が見つかりませんでした」通知先のアドレスが Teams のサインインのアドレスと違う6章の通知先を、Teams にサインインするアドレスに直す
チャネルにカードが届かないテスト送信先が入っている/チャネル名が工場名と違う/プライベート チャネル/フローの所有者がチームのメンバーでない6章・7章を確認する。届かない工場の分は情シスの通知先に届いています
カードがどこにも届かないWorkflows アプリが許可されていないTeams 管理センターで許可する
ファイルがロックされて更新できないExcel のコネクタの使用後、最大6分ロックされることがある数分待つ。フローの時刻を更新作業とずらす
エージェントが古い結果を答えるナレッジへの反映(4〜6時間ごとの同期が目安)がまだ反映を待つ。急ぐ内容は Teams の通知で確認する

12-2 よくあるご質問

ご質問回答
カードに回答が無いときは?実証ログには記録されません。フローは最長30日回答を待ち、期限を過ぎた工場の分は「情シスの通知先」に「回答が無いまま期限を過ぎた」旨のメッセージが届きます。実行は失敗にならず、ほかの工場のカードにも影響しません(カードを送れなかった場合も同じ動きです。当社の環境で、送れなかった場合を確認)。
回答のあとのチャットも記録される?記録されるのは、カードの入力欄(確認した結果・確認した内容・原因・対応したこと)と回答した方の名前だけです。その後のチャットのやり取りは記録されません。
パソコンの電源を切っても動く?動きます。クラウド フローは Microsoft のクラウドで動きます。ブラウザーで作成・管理できます。
何人分のフローが要る?どなたか1人のアカウントに1本で足ります。担当の方が替わる場合に備えて、フローの詳細の画面の 共同所有者 にもう1人追加しておくと、作成した方がいなくなってもフローは動き続けます。ただし、作成した方の接続を使うアクションは失敗することがあるため、担当が替わるときは接続を新しい担当の方のものに付け替えてください。
フローが止まることは?14日間失敗が続いたフローは、自動でオフになります。失敗の通知が届いたら12-1 の表で確認してください。
スクリプトを新しくしたら?.osts を同じ名前で置き換えれば、そのまま使えます。引数が変わる版をお送りした場合は、4章で設定ファイルを作り直して取り込み直します(Microsoft の案内で、引数が変わったときはスクリプトの実行のアクションを作り直す必要があるため)。
設定ファイルを取り込み直したら?取り込み直すと新しいフローができます。前のフローは、フローの詳細の画面で 有効 をオフにしてから削除してください(オンのまま残すと、毎朝の確認依頼が二重になります)。6章で変えた値は、作り直した設定ファイルの値になります。

A付録A スクリプト全文

この画面に載せるため、ファイル名などの表記を一般的な名前に置き換えています。お使いいただくのは、メールでお送りした .osts(または .txt)です。

RFID読取率_日次チェック(Office Scripts)
/**
 * RFID読取率 日次チェック(Office Scripts)
 * 株式会社デジライズ 2026-10-19 版(10/16 お打合せ反映:読取率の下限を引数 rateLow で変更可能に)
 *
 * 集計ファイルの月別シート(工場読取データ (N月))を読み、
 * 対象日のデータで仮説に当てはまるものを判定して、Power Automate に結果を返します。
 * ブックの中身は書き換えません(読み取りだけ)。
 *
 * 使い方:SharePoint のライブラリに置いた .osts を、Power Automate の「SharePoint ライブラリからスクリプトを実行する」で呼び出します。
 *         (Excel on the web の[自動化]→[新しいスクリプト]に全文を貼り付けて登録し、「スクリプトの実行」で呼び出すこともできます)
 * 戻り値:判定結果(DailyResult)を JSON にした文字列
 * 引数  :targetDate   … 判定する日(YYYY-MM-DD)。空欄ならデータの最終日
 *         expectedDate … 本来入っているはずの日。空欄なら日本時間の前日。"-" なら確認しない(過去日の試験用)
 *         mode         … "daily"(毎日の判定)または "period"(月内の傾向の判定。拠点への通知には使わない)
 *         rateLow      … 読取率の下限(%)。これを下回ると「低い」とみなす。空欄なら 85(分析ルール台帳 Ver3)。例:90
 *                        使う判定:P06(ゲートの8割以上の商品が下限未満)・P01/P03(ゲート全体が下限未満)・P17(その商品だけ期間中央値が下限未満)
 * 毎日の判定:対象日 … P04(対象数0・分母0の商品)、P06、P12(出荷のみ)
 *             3日前 … P04(対象数の急減で戻らないもの)、P01・P03(日ズレ。ゲート全体)
 * 当月の全日で読取数が0のゲートは「未取得」として判定から外します。
 */

// ---------- 閾値(分析判定閾値マスタ Ver3。指示文の【閾値】と必ず揃える) ----------
const RATE_LOW_DEFAULT = 0.85;   // 85%未満=異常(既定。引数 rateLow で変えられる)
const RATE_HI = 1.0;     // 100%超=要注意
const QTY_WARN = 0.10;   // 前日比10%以上=要注意
const QTY_ABN = 0.20;    // 前日比20%以上=異常候補
const BUSY = 1.2;        // 繁忙日=中央値の1.2倍以上

// ---------- 仮説マスタ(判定できる11件。名称と「現場に確認すること」) ----------
const HYP: { [id: string]: { name: string; check: string } } = {
  P01: { name: "回収と前処理でチップを読ませる日が異っている", check: "回収ゲートの通過と前処理の通過のサイクルがどうなっているのか確認する" },
  P03: { name: "出荷日と仕上日がずれている", check: "出荷ゲートの通過と仕上の通過のサイクルがどうなっているのか確認する" },
  P04: { name: "前処理・仕上など工程データが欠損している", check: "対象工場のゲートが集計対象に入っているか、いつから連携が止まっているかをシステム側で確認する" },
  P05: { name: "分子・分母、対象顧客、日付の集計ロジックが不整合", check: "分子・分母それぞれの集計の抽出条件(対象得意先、対象商品、日付の締め時刻)が一致しているか確認する" },
  P06: { name: "リーダー、本体または電源系が故障している", check: "対象日にリーダー本体の再起動・電源断・保守作業がなかったか、機器のエラーログが残っていないか確認する" },
  P08: { name: "アンテナの出力・感度が低下している", check: "アンテナの出力設定値、設置後の経過年数、直近の調整履歴を確認する" },
  P12: { name: "未タグ品が処理数に含まれている", check: "対象商品にタグが全数装着されているか、未装着品の運用ルールがあるか確認する" },
  P17: { name: "RFIDタグの向きが読取安定エリアに適合していない", check: "該当商品のタグ縫い付け位置と、ゲート通過時の向きを現物で確認する" },
  P19: { name: "濡れた状態での読取(回収が出荷より恒常的に低い)", check: "回収時の濡れ具合と、ゲート通過時の積み方を確認する" },
  P20: { name: "枚数が多い日ほど読取率が下がる", check: "繁忙日のゲート通過の速度・積載量を確認する" },
  P21: { name: "繁忙日に読取率の低下が偏る", check: "繁忙日の作業手順(通過速度・積み方)が通常日と違っていないか確認する" },
};

// ---------- 型 ----------
interface Rec {
  factory: string; gate: string; custName: string; cd: string; name: string;
  date: string; num: number | null; den: number | null; rate: number | null;
}
interface Hit {
  hypothesisId: string; hypothesisName: string; factory: string; gate: string;
  targetDate: string; scope: string; evidence: string; check: string;
}
interface Group {
  factory: string; hitCount: number; hypothesisIds: string; text: string;
  cardText: string;   // Teams のカード(JSON)に埋め込む用。改行や引用符をエスケープ済み
}
interface DailyResult {
  targetDate: string; latestDate: string; expectedDate: string; isUpdated: boolean;
  message: string; hitCount: number; hits: Hit[]; groups: Group[]; notAcquired: string[];
  rateLow: number;   // この判定に使った読取率の下限(%)
}

// ---------- 入口 ----------
function main(workbook: ExcelScript.Workbook, targetDate?: string, expectedDate?: string, mode?: string, rateLow?: number): string {
  // 読み込むのは対象月と前月のシートだけ(実行時間の上限120秒に収めるため)
  // 対象日も確認日も指定しない試験(expectedDate = "-")のときは、全部の月を読む
  const jstToday = new Date(Date.now() + 9 * 3600000).toISOString().slice(0, 10);
  const readAll = !targetDate && expectedDate === "-";
  const ref = targetDate || (expectedDate && expectedDate !== "-" ? expectedDate : jstToday);
  const m = parseInt(ref.slice(5, 7)), pm = m === 1 ? 12 : m - 1;
  const sheets: { name: string; values: (string | number | boolean)[][] }[] = [];
  for (const ws of workbook.getWorksheets()) {
    const name = ws.getName();
    if (!isMonthSheet(name)) continue;
    const mm = name.match(/\((\d+)月\)/);
    if (!mm) continue;
    if (!readAll && parseInt(mm[1]) !== m && parseInt(mm[1]) !== pm) continue;
    const used = ws.getUsedRange(true);
    if (!used) continue;
    // 使われている範囲は「値のある最初の行・列」から始まるため、A1 から最後の行・列までを読む(列の位置で読むので)
    const all = ws.getRangeByIndexes(0, 0, used.getRowIndex() + used.getRowCount(), used.getColumnIndex() + used.getColumnCount());
    sheets.push({ name: name, values: all.getValues() });
  }
  // expectedDate が空欄なら「日本時間の前日」。"-" なら最新データの確認をしない(過去日の試験用)
  const jstYesterday = new Date(Date.now() + 9 * 3600000 - 86400000).toISOString().slice(0, 10);
  const exp = expectedDate === "-" ? "" : (expectedDate || jstYesterday);
  // 結果は JSON の文字列で返す(フローの「作成」で json() に通して使う)
  return JSON.stringify(runChecks(sheets, targetDate || "", exp, mode || "daily", toRateLow(rateLow)));
}

// 読取率の下限を 0〜1 の比率にする(90 でも 0.9 でもよい。空欄・範囲外は既定の 85%)
function toRateLow(v?: number): number {
  const n = parseFloat(String(v));   // フローから文字で渡されても読む
  if (isNaN(n) || n <= 0) return RATE_LOW_DEFAULT;
  const r = n > 1 ? n / 100 : n;
  return r >= 0.5 && r <= 1 ? r : RATE_LOW_DEFAULT;
}

// ---------- 本体(ブックに依存しない部分。手元の検証でもこのまま使う) ----------
function runChecks(sheets: { name: string; values: (string | number | boolean)[][] }[], targetDate: string, expectedDate: string, mode: string, low: number): DailyResult {
  const lowPct = Math.round(low * 1000) / 10;
  // 1) 月別シートを商品単位のレコードに展開
  let recs: Rec[] = [];
  for (const s of sheets) {
    const m = s.name.match(/\((\d+)月\)/);
    recs = recs.concat(extractSheet(s.values, m ? parseInt(m[1]) : 0));
  }
  // 2) 最新データの確認
  let latest = "";
  for (const r of recs) {
    if (((r.num ?? 0) > 0 || (r.den ?? 0) > 0) && r.date > latest) latest = r.date;
  }
  if (!latest) {
    return { targetDate: targetDate, latestDate: "", expectedDate: expectedDate, isUpdated: false,
             message: "対象の月のシート(工場読取データ (N月))にデータが見つかりませんでした。シート名と対象日を確認してください。",
             hitCount: 0, hits: [], groups: [], notAcquired: [], rateLow: lowPct };
  }
  const D = targetDate || latest;
  const isUpdated = expectedDate ? latest >= expectedDate : true;
  // 対象日を指定せずに最新データを待っている場合、まだ入っていなければ判定しない(古い日の再通知を防ぐ)
  if (!isUpdated && !targetDate) {
    return { targetDate: "", latestDate: latest, expectedDate: expectedDate, isUpdated: false,
             message: "集計ファイルの最新日が " + latest + " で、" + expectedDate + " のデータがまだ入っていません。",
             hitCount: 0, hits: [], groups: [], notAcquired: [], rateLow: lowPct };
  }
  // 対象日の月と前月だけを使う(毎日の判定はこの範囲で足りる)
  const useMonths = mode === "period-all" ? null : [D.slice(0, 7), addDays(D.slice(0, 7) + "-01", -1).slice(0, 7)];
  if (useMonths) recs = recs.filter(r => useMonths.indexOf(r.date.slice(0, 7)) >= 0);

  const hits: Hit[] = [];
  const notAcquired: string[] = [];
  const idx = buildIndex(recs);

  // 当月の全日で読取数0のゲート=未取得(異常として数えず、判定からも外す)
  const ym = D.slice(0, 7);
  const skipGate: { [k: string]: boolean } = {};
  for (const fg of Object.keys(idx.byFG)) {
    const xs = idx.byFG[fg].filter(r => r.date.slice(0, 7) === ym && r.date <= D);
    if (xs.length > 0 && xs.every(r => (r.num ?? 0) === 0)) {
      skipGate[fg] = true;
      notAcquired.push(fg.replace("|", " ") + "(" + ym + "は読取数0のため未取得として除外)");
    }
  }

  if (mode === "daily") {
    dailyChecks(D, idx, skipGate, hits, low);
    // 日ズレ(P01・P03)は1〜3日後の枚数と比べるため、3日前の日を判定する
    lagChecks(addDays(D, -3), idx, skipGate, hits, low);
  } else {
    periodChecks(idx, skipGate, hits, low);
  }

  const groups = groupByFactory(hits);
  let message = "";
  if (!isUpdated) message = "集計ファイルの最新日が " + latest + " で、" + expectedDate + " のデータがまだ入っていません。";
  else if (hits.length === 0) message = D + " のデータで、仮説に当てはまるものはありませんでした。";
  else message = D + " のデータで、" + groups.length + "工場・" + hits.length + "件が仮説に当てはまりました(読取率の下限 " + lowPct + "%)。";

  return { targetDate: D, latestDate: latest, expectedDate: expectedDate, isUpdated: isUpdated,
           message: message, hitCount: hits.length, hits: hits, groups: groups, notAcquired: notAcquired, rateLow: lowPct };
}

// ---------- シートの読み取り ----------
function isMonthSheet(name: string): boolean {
  return name.indexOf("工場読取データ") === 0 && name.indexOf("テンプレート") < 0 && name.indexOf("OLD") < 0 && name.indexOf("重複") < 0;
}

function toDate(v: string | number | boolean): string {
  if (typeof v === "number" && v > 40000 && v < 60000) {
    const ms = Date.UTC(1899, 11, 30) + Math.floor(v) * 86400000;
    return new Date(ms).toISOString().slice(0, 10);
  }
  if (typeof v === "string") {
    const m = v.match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})/);
    if (m) return m[1] + "-" + ("0" + m[2]).slice(-2) + "-" + ("0" + m[3]).slice(-2);
  }
  return "";
}

function numOrNull(v: string | number | boolean): number | null {
  return typeof v === "number" ? v : null;
}

function extractSheet(rows: (string | number | boolean)[][], month: number): Rec[] {
  const out: Rec[] = [];
  const heads: number[] = [];
  for (let i = 0; i < rows.length; i++) {
    if (String(rows[i][0] ?? "").indexOf("ゲート読取率") >= 0) heads.push(i);
  }
  for (let bi = 0; bi < heads.length; bi++) {
    const h = heads[bi];
    const title = String(rows[h][0]).replace(/ /g, " ").trim();
    const factory = title.split(" ")[0];
    const gate = title.indexOf("回収ゲート") >= 0 ? "回収" : (title.indexOf("出荷ゲート") >= 0 ? "出荷" : "不明");
    const custRow = rows[h + 1], hdrRow = rows[h + 2];
    if (!hdrRow || hdrRow[0] !== "No") continue;
    const custName = String(custRow[1] ?? "");
    // 日付の列(6列目から3列おき)。同じ日付は初出だけ使い、シート末尾の翌月の日付は除く
    const dateCols: { c: number; d: string }[] = [];
    const seen: { [d: string]: boolean } = {};
    for (let c = 5; c < custRow.length; c += 3) {
      const d = toDate(custRow[c]);
      if (!d || seen[d]) continue;
      if (month && parseInt(d.slice(5, 7)) !== month) continue;
      seen[d] = true;
      dateCols.push({ c: c, d: d });
    }
    const end = bi + 1 < heads.length ? heads[bi + 1] : rows.length;
    for (let i = h + 3; i < end; i++) {
      const r = rows[i];
      if (!r || typeof r[0] !== "number") continue;
      const name = r[1];
      if (name === "" || name === null || name === undefined) continue;
      for (const dc of dateCols) {
        if (dc.c + 1 >= r.length) continue;
        const num = numOrNull(r[dc.c]), den = numOrNull(r[dc.c + 1]);
        if (num === null && den === null) continue;
        out.push({ factory: factory, gate: gate, custName: custName, cd: String(r[2]), name: String(name), date: dc.d,
                   num: num, den: den, rate: (num !== null && den) ? num / den : null });
      }
    }
  }
  return out;
}

// ---------- 索引 ----------
interface Idx {
  byFG: { [fg: string]: Rec[] };
  byFGD: { [fgd: string]: Rec[] };
  byFGP: { [fgp: string]: Rec[] };
  byFGPD: { [fgpd: string]: Rec };
}
function buildIndex(recs: Rec[]): Idx {
  const idx: Idx = { byFG: {}, byFGD: {}, byFGP: {}, byFGPD: {} };
  for (const r of recs) {
    const fg = r.factory + "|" + r.gate;
    (idx.byFG[fg] = idx.byFG[fg] || []).push(r);
    (idx.byFGD[fg + "|" + r.date] = idx.byFGD[fg + "|" + r.date] || []).push(r);
    (idx.byFGP[fg + "|" + r.cd] = idx.byFGP[fg + "|" + r.cd] || []).push(r);
    idx.byFGPD[fg + "|" + r.cd + "|" + r.date] = r;
  }
  for (const k of Object.keys(idx.byFGP)) idx.byFGP[k].sort((a, b) => a.date < b.date ? -1 : 1);
  return idx;
}

function addDays(d: string, n: number): string {
  const t = Date.UTC(parseInt(d.slice(0, 4)), parseInt(d.slice(5, 7)) - 1, parseInt(d.slice(8, 10))) + n * 86400000;
  return new Date(t).toISOString().slice(0, 10);
}
function pct(x: number): string { return (x * 100).toFixed(1) + "%"; }
function fmtLow(x: number): string { return (Math.round(x * 1000) / 10) + "%"; }
function signedPct(x: number): string { return (x >= 0 ? "+" : "") + Math.round(x * 100) + "%"; }
function median(xs: number[]): number | null {
  if (xs.length === 0) return null;
  const s = xs.slice().sort((a, b) => a - b), n = s.length;
  return n % 2 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2;
}
function dayTotal(xs: Rec[]): { num: number; den: number } {
  let num = 0, den = 0;
  for (const x of xs) { num += x.num ?? 0; den += x.den ?? 0; }
  return { num: num, den: den };
}
function push(hits: Hit[], id: string, fg: string, date: string, scope: string, evidence: string) {
  const [f, g] = fg.split("|");
  hits.push({ hypothesisId: id, hypothesisName: HYP[id].name, factory: f, gate: g, targetDate: date,
              scope: scope, evidence: evidence, check: HYP[id].check });
}

// ---------- 毎日の判定(対象日 D) ----------
function dailyChecks(D: string, idx: Idx, skip: { [k: string]: boolean }, hits: Hit[], low: number) {
  const prev = addDays(D, -1);
  for (const fg of Object.keys(idx.byFG)) {
    if (skip[fg]) continue;
    const today = idx.byFGD[fg + "|" + D] || [];
    const yday = idx.byFGD[fg + "|" + prev] || [];
    if (today.length === 0) continue;

    // P04-1 対象数(分母)が0なのに読取数がある商品
    const zeroDen = today.filter(r => r.den === 0 && (r.num ?? 0) > 0);
    if (zeroDen.length > 0) {
      const ex = zeroDen.slice(0, 3).map(r => r.name + "(" + r.cd + ")読取" + r.num + "枚").join("、");
      push(hits, "P04", fg, D, "商品単位", "対象数(分母)が0なのに読取数がある商品が" + zeroDen.length + "件。例:" + ex + "。対象工程のデータ未取込の疑い");
    }
    // P04-2 ゲート全体の対象数が0になった(前日はあった)。急減の判定は3日前の日で行う(lagChecks)
    const a = dayTotal(yday), b = dayTotal(today);
    if (a.den > 0 && b.den === 0) {
      push(hits, "P04", fg, D, "ゲート全体", "ゲート全体の対象数が0(前日は" + a.den + "枚)。前処理・仕上のデータが入っていない疑い");
    }
    // P06 その日、8割以上の商品が下限(既定85%)未満(前日は8割以上が正常)
    const vv = today.filter(r => r.den);
    const pv = yday.filter(r => r.den);
    if (vv.length >= 3 && pv.length > 0) {
      const lows = vv.filter(r => r.rate !== null && r.rate < low);
      const pok = pv.filter(r => r.rate !== null && r.rate >= low);
      if (lows.length / vv.length >= 0.8 && pok.length / pv.length >= 0.8) {
        push(hits, "P06", fg, D, "ゲート全体", vv.length + "商品中" + lows.length + "商品(" + Math.round(lows.length / vv.length * 100) + "%)が" + fmtLow(low) + "未満。前日は正常で、特定商品ではなくゲート全体の事象");
      }
    }
    // P12 出荷ゲートのみ:仕上(分母)だけ前日比20%以上増え、出荷(分子)がほぼ横ばい
    if (fg.split("|")[1] === "出荷") {
      const p12: string[] = [];
      for (const r of today) {
        const y = idx.byFGPD[fg + "|" + r.cd + "|" + prev];
        if (!y || !y.den || !y.num || r.den === null || r.num === null) continue;
        const dd = (r.den - y.den) / y.den, dn = (r.num - y.num) / y.num;
        if (dd >= QTY_ABN && Math.abs(dn) < QTY_WARN) p12.push(r.name + "(" + r.cd + ")仕上" + signedPct(dd) + "・出荷" + signedPct(dn));
      }
      if (p12.length > 0) {
        push(hits, "P12", fg, D, "商品単位", "仕上だけ増えて出荷が増えていない商品が" + p12.length + "件。例:" + p12.slice(0, 3).join("、") + "。未タグ品混入の候補");
      }
    }
  }
}

// ---------- 3日前の日の判定:対象数の急減(P04)と日ズレ(P01・P03) ----------
// どちらも「1〜3日後の枚数」と比べるため、3日分のデータがそろった日を対象にする
function lagChecks(D: string, idx: Idx, skip: { [k: string]: boolean }, hits: Hit[], low: number) {
  for (const fg of Object.keys(idx.byFG)) {
    if (skip[fg]) continue;
    // P04-3 ゲート全体の対象数が前日比20%以上急減し、3日後までに戻らない
    const a = dayTotal(idx.byFGD[fg + "|" + addDays(D, -1)] || []), b = dayTotal(idx.byFGD[fg + "|" + D] || []);
    if (a.den > 0 && b.den > 0) {
      const ch = (b.den - a.den) / a.den;
      const fwd: number[] = [];
      // データのない日(休業日など)は飛ばし、データのある次の3日で見る
      for (let k = 1; k <= 7 && fwd.length < 3; k++) { const z = idx.byFGD[fg + "|" + addDays(D, k)]; if (z) fwd.push(dayTotal(z).den); }
      if (ch <= -QTY_ABN && !(fwd.length > 0 && Math.max(...fwd) >= a.den * (1 - QTY_WARN))) {
        push(hits, "P04", fg, D, "ゲート全体", "ゲート全体の対象数が前日比" + signedPct(ch) + "に急減(" + a.den + "→" + b.den + "枚)し、" + fwd.length + "日後までに戻っていない");
      }
    }
    // 日ズレは、分子・分母の両方がある行だけで合計する
    const t = dayTotal((idx.byFGD[fg + "|" + D] || []).filter(r => r.num !== null && r.den !== null));
    if (t.den <= 0 || t.num <= 0) continue;
    const rate = t.num / t.den;
    if (rate >= low) continue;
    const gate = fg.split("|")[1];
    const id = gate === "回収" ? "P01" : "P03";
    const lbl = gate === "回収" ? "前処理" : "仕上";
    for (let lag = 1; lag <= 3; lag++) {
      const y = dayTotal((idx.byFGD[fg + "|" + addDays(D, lag)] || []).filter(r => r.num !== null && r.den !== null));
      if (y.den <= 0) continue;
      const diff = Math.abs(t.num - y.den) / y.den;
      if (diff <= QTY_WARN) {
        push(hits, id, fg, D, "ゲート全体", "当日の読取数" + t.num + "枚が" + lag + "日後の" + lbl + y.den + "枚とほぼ一致(差" + Math.round(diff * 100) + "%)。当日読取率" + pct(rate));
        break;
      }
    }
  }
}

// ---------- 月内の傾向(P05・P08・P17・P19・P20・P21) ----------
function periodChecks(idx: Idx, skip: { [k: string]: boolean }, hits: Hit[], low: number) {
  const med: { [fgp: string]: number | null } = {};
  for (const k of Object.keys(idx.byFGP)) {
    med[k] = median(idx.byFGP[k].filter(x => x.den && x.rate !== null).map(x => x.rate as number));
  }
  const label = "(期間通算)";
  for (const k of Object.keys(idx.byFGP)) {
    const [f, g, p] = k.split("|"); const fg = f + "|" + g;
    if (skip[fg]) continue;
    const v = idx.byFGP[k]; const last = v[v.length - 1];
    const vv = v.filter(x => x.den && x.rate !== null);
    const m = med[k];
    // P05 恒常的に100%超
    if (m !== null && m > RATE_HI && vv.length >= 10) push(hits, "P05", fg, label, "商品単位", last.name + "(" + p + ")の期間中央値" + pct(m) + "(" + vv.length + "日)。100%超は集計ロジック不整合の候補");
    // P19 同一商品で回収が出荷より10pt以上低い
    if (g === "回収" && m !== null) {
      const m2 = med[f + "|出荷|" + p];
      if (m2 !== null && m2 !== undefined && m2 - m >= 0.10) push(hits, "P19", fg, label, "商品単位", last.name + "(" + p + ")回収" + pct(m) + "に対し出荷" + pct(m2));
    }
    if (vv.length >= 10) {
      // P20 枚数と読取率の相関
      const xs = vv.map(x => x.den as number), ys = vv.map(x => x.rate as number);
      const n = xs.length, mx = xs.reduce((s, x) => s + x, 0) / n, my = ys.reduce((s, y) => s + y, 0) / n;
      const sx = Math.sqrt(xs.reduce((s, x) => s + (x - mx) ** 2, 0)), sy = Math.sqrt(ys.reduce((s, y) => s + (y - my) ** 2, 0));
      if (sx > 0 && sy > 0) {
        const r = xs.reduce((s, x, i) => s + (x - mx) * (ys[i] - my), 0) / (sx * sy);
        if (r <= -0.30) push(hits, "P20", fg, label, "商品単位", last.name + "(" + p + ")枚数と読取率の相関係数 " + r.toFixed(2) + "(" + n + "日)");
      }
      // P21 繁忙日に低下が偏る
      const mq = median(xs) as number;
      const busy = vv.filter(x => (x.den as number) >= mq * BUSY).map(x => x.rate as number);
      const norm = vv.filter(x => (x.den as number) < mq * BUSY).map(x => x.rate as number);
      if (busy.length >= 3 && norm.length >= 3) {
        const gap = norm.reduce((s, x) => s + x, 0) / norm.length - busy.reduce((s, x) => s + x, 0) / busy.length;
        if (gap >= 0.05) push(hits, "P21", fg, label, "商品単位", last.name + "(" + p + ")繁忙日の平均が通常日より" + (gap * 100).toFixed(1) + "pt低い");
      }
    }
  }
  // P17 特定商品だけ恒常的に低い(ゲート全体は正常)
  const byGate: { [fg: string]: { p: string; m: number; name: string }[] } = {};
  for (const k of Object.keys(med)) {
    const m = med[k]; if (m === null) continue;
    const [f, g, p] = k.split("|"); const fg = f + "|" + g;
    const v = idx.byFGP[k];
    (byGate[fg] = byGate[fg] || []).push({ p: p, m: m, name: v[v.length - 1].name });
  }
  for (const fg of Object.keys(byGate)) {
    if (skip[fg]) continue;
    const ms = byGate[fg]; if (ms.length < 3) continue;
    const ok = median(ms.map(x => x.m)) as number;
    if (ok < low) continue;
    for (const x of ms) if (x.m < low) push(hits, "P17", fg, label, "商品単位", x.name + "(" + x.p + ")だけ期間中央値" + pct(x.m) + "(他商品の中央値" + pct(ok) + ")");
  }
  // P08 直近4週の週平均が単調に10pt以上低下
  for (const k of Object.keys(idx.byFGP)) {
    const [f, g, p] = k.split("|"); const fg = f + "|" + g;
    if (skip[fg]) continue;
    const vv = idx.byFGP[k].filter(x => x.den && x.rate !== null);
    if (vv.length < 28) continue;
    const wk: { [w: string]: number[] } = {};
    for (const x of vv) { const w = isoWeek(x.date); (wk[w] = wk[w] || []).push(x.rate as number); }
    const ks = Object.keys(wk).sort(); if (ks.length < 4) continue;
    const avg = ks.map(w => wk[w].reduce((s, y) => s + y, 0) / wk[w].length);
    const t = avg.slice(-4);
    if (t[0] > t[1] && t[1] > t[2] && t[2] > t[3] && t[0] - t[3] >= 0.10) push(hits, "P08", fg, label, "商品単位", vv[vv.length - 1].name + "(" + p + ")直近4週の週平均が単調低下(" + pct(t[0]) + "→" + pct(t[3]) + ")");
  }
}
function isoWeek(d: string): string {
  const dt = new Date(Date.UTC(parseInt(d.slice(0, 4)), parseInt(d.slice(5, 7)) - 1, parseInt(d.slice(8, 10))));
  const day = (dt.getUTCDay() + 6) % 7;
  dt.setUTCDate(dt.getUTCDate() - day + 3);
  const y = dt.getUTCFullYear();
  const first = new Date(Date.UTC(y, 0, 4));
  const wn = 1 + Math.round(((dt.getTime() - first.getTime()) / 86400000 - 3 + ((first.getUTCDay() + 6) % 7)) / 7);
  return y + "-" + ("0" + wn).slice(-2);
}

// ---------- 工場ごとにまとめる(Teams には1工場1通) ----------
function groupByFactory(hits: Hit[]): Group[] {
  const by: { [f: string]: Hit[] } = {};
  for (const h of hits) (by[h.factory] = by[h.factory] || []).push(h);
  const out: Group[] = [];
  for (const f of Object.keys(by).sort()) {
    const hs = by[f];
    const ids = hs.map(h => h.hypothesisId).filter((x, i, a) => a.indexOf(x) === i).join("・");
    // Teams のカードで改行されるよう、行の区切りは空行(\n\n)にする
    const text = hs.map(h => "**■" + h.gate + "ゲート " + h.targetDate + " " + h.hypothesisId + " " + h.hypothesisName + "**" +
      "\n\n根拠:" + h.evidence + "\n\n現場に確認すること:" + h.check).join("\n\n");
    out.push({ factory: f, hitCount: hs.length, hypothesisIds: ids, text: text, cardText: JSON.stringify(text).slice(1, -1) });
  }
  return out;
}

B付録B 確認依頼カード(Adaptive Card 1.4)

設定ファイルのフローに組み込んであります。文言を変えたいときの参考です(フローの「カードをチャネルに送る」「カードを個人に送る」の メッセージ の欄)。

Teams 確認依頼カード
{
 "type": "AdaptiveCard",
 "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
 "version": "1.4",
 "body": [
  {
   "type": "TextBlock",
   "text": "読取状況の確認のお願い",
   "weight": "Bolder",
   "size": "Medium",
   "wrap": true
  },
  {
   "type": "FactSet",
   "facts": [
    {
     "title": "工場",
     "value": "@{items('工場ごと')?['factory']}"
    },
    {
     "title": "対象日",
     "value": "@{outputs('判定結果')?['targetDate']}"
    },
    {
     "title": "該当件数",
     "value": "@{items('工場ごと')?['hitCount']}件(@{items('工場ごと')?['hypothesisIds']})"
    }
   ]
  },
  {
   "type": "TextBlock",
   "text": "@{items('工場ごと')?['cardText']}",
   "wrap": true,
   "spacing": "Medium"
  },
  {
   "type": "TextBlock",
   "text": "現場で確認した結果を入力し、[回答する]を押してください。",
   "wrap": true,
   "spacing": "Large",
   "weight": "Bolder"
  },
  {
   "type": "Input.ChoiceSet",
   "id": "result",
   "label": "確認した結果",
   "isRequired": true,
   "errorMessage": "確認した結果を選んでください",
   "style": "expanded",
   "choices": [
    {
     "title": "仮説のとおりだった(原因が分かった)",
     "value": "仮説のとおり"
    },
    {
     "title": "仮説とは違った",
     "value": "仮説と違う"
    },
    {
     "title": "まだ確認中・分からない",
     "value": "確認中"
    }
   ]
  },
  {
   "type": "Input.Text",
   "id": "cause",
   "label": "確認した内容・原因",
   "isMultiline": true,
   "placeholder": "例:前日の仕上が夜間にずれ込み、翌朝に出荷ゲートを通していた"
  },
  {
   "type": "Input.Text",
   "id": "action",
   "label": "対応したこと",
   "isMultiline": true,
   "placeholder": "例:出荷ゲートを通す時刻を仕上の当日中に変更した"
  }
 ],
 "actions": [
  {
   "type": "Action.Submit",
   "title": "回答する"
  }
 ]
}

C付録C フローの中身

アクションの名前種類コネクタ内容
毎朝の起動繰り返し組み込み集計ファイルの前日分の更新が終わった後の時刻にする(更新中に動かさない)
読取率の下限変数を初期化する組み込み単位は %。これを下回ると「低い」とみなす(例:85/90)。変えたら次の朝から反映
情シスの通知先変数を初期化する組み込み未更新のお知らせと、チャネルが見つからない工場の確認依頼を送る方のメールアドレス
テスト送信先変数を初期化する組み込み試験中は試験する方のメールアドレス(すべての確認依頼がこの方に届く)。本番は空欄にする
チームID変数を初期化する組み込み工場ごとのチャネルがあるチーム。チャネル名が工場名と同じチャネルに確認依頼を送る
判定SharePoint ライブラリからスクリプトを実行するExcel Online (Business)集計ファイルを読むだけ(書き換えない)。targetDate=判定する日(空欄=データの最終日)、expectedDate=入っているはずの日(空欄=日本時間の前日、- =確認しない)
判定結果作成組み込みスクリプトが返した JSON を読む
前日分が入っているか条件組み込み
はい:チャネルを探すか条件組み込み
はい:チャネルの一覧チャネルを一覧表示するMicrosoft Teamsチームのチャネルの一覧(チャネル名で工場を探す)
はい:該当なしの記録条件組み込み
はい:該当なしを記録項目を作成するSharePointエージェントが「最新日は該当なし」と答えられるよう、該当が無い日も1行残す
はい:工場ごとそれぞれに適用する組み込み工場ごとに1通。同時実行10(オフのままだと、1つ目の工場が回答するまで次の工場にカードが届かない)
繰り返しの中:日次判定結果に記録項目を作成するSharePoint
繰り返しの中:その工場のチャネルアレイのフィルター処理組み込みチャネル名が工場名(「工場」を除いた名前でもよい)と同じ標準チャネル(プライベート・共有チャネルには送れないため除く)
繰り返しの中:送り先条件組み込みテスト送信先が空欄で、工場のチャネルが見つかればチャネルへ。それ以外は個人あて
はい:カードをチャネルに送るアダプティブ カードをポストして応答を待つMicrosoft Teams工場のチャネルに確認依頼を送り、最初の回答を待つ(最大30日)
いいえ:カードを個人に送るアダプティブ カードをポストして応答を待つMicrosoft Teams試験中はテスト送信先へ。本番でチャネルが見つからない工場は情シスの通知先へ
繰り返しの中:回答作成組み込み
繰り返しの中:回答なしの連絡チャットまたはチャネルでメッセージを投稿するMicrosoft Teams回答が無いまま期限を過ぎた・カードを送れなかったときだけ動く(実行を失敗で終わらせず、情シスに知らせる)
繰り返しの中:実証ログに記録項目を作成するSharePoint回答があったときだけ記録する(回答が無いまま30日たつと、この工場の分は記録されずに終わる)
繰り返しの中:この工場の終わり作成組み込み回答の記録か、回答なしの連絡のどちらかが済んだら終わる
いいえ:未更新の通知チャットまたはチャネルでメッセージを投稿するMicrosoft Teams