kintone アプリストアの 在庫管理パックは、製品マスタと入出庫の記録アプリが最初から入っている便利なパックです。ただ、入出庫を記録しても、「今いくつあるのか」はどこにも出ません。レコードは 1 件 1 件の取引であって、在庫はその全部を足し合わせた残高だからです。
そこで kSQL Dashboard Pro(有償プラグイン)を使い、入出庫アプリの一覧画面の上に「現在庫・在庫金額・要発注」がひと目で分かるダッシュボードを作ります。
SFA パック編の続編です。SFA 編は「期間で切って足す」集計が主役でしたが、在庫は**期間で切れない「残高型」**という別系統の集計になります。プラグインの基本操作(ペインの追加・設定画面の使い方)は SFA 編と製品紹介記事を参照してください。本記事は在庫パックでの実践に絞ります。
この記事では、SQL・ペイン設定・表示結果を 1 セットにして、3 タブ 14 ペインのダッシュボード、在庫日数まで踏み込む分析ビュー、そしてすべての一覧のヘッダーに出る共通サマリーを作ります。SQL はすべてコピペで動きます。
- 在庫サマリー
- 在庫サマリータブ
- 入出庫の遷移タブ
- 滞留・終売タブ
前提(この記事で使うバージョン・アプリ)
バージョンの前提はここだけです。以降の本文では省略します。
| 項目 | 本記事の前提 |
|---|---|
| プラグイン | kSQL Dashboard Pro Ver.1 |
| SQL エンジン | kintone-sql-tools 3.66.1 系 |
| kintone | 公式対応ブラウザの最新版(PC)。モバイルは対象外 |
| 対象アプリ | アプリストア 在庫管理パック |
| 製品在庫管理 |
APP4228(入出庫の履歴)… ダッシュボードを置くアプリ
|
| 製品マスタ |
APP4229(8 品目。パック標準のまま使う) |
アプリ番号は環境ごとに変わります。
APP4228などの数字は、ご自身の環境の番号(アプリの URL の/k/4228/の部分)に読み替えてください。
設定 JSON の取り込みでは書き換え不要です。 取り込み時の確認ダイアログ「アプリ番号の変換」に変換先を入力すると、SQL 中の番号を自動で置き換えて取り込めます。
サンプルデータを入れ替える(最初にやります)
パック標準のサンプルデータは入出庫が 12 件しかなく、日付も過去で固定されています。これでは残高がほとんど動かず、相対日付のペインは 0 件になります。
そこで、実行日までの 1 年に散らした入出庫 1,000 件を投入するスクリプトを用意しました(全文は記事末尾の付録)。Node 18 以上で動きます(依存パッケージなし)。
node load-stock-data.mjs 下見(何も書き込まない)
node load-stock-data.mjs --apply --clean 標準サンプル 12 件を消して 1,000 件を投入
環境変数 KINTONE_BASE_URL と、KINTONE_USERNAME / KINTONE_PASSWORD(または KINTONE_API_TOKEN)を設定し、アプリ番号が違う場合は --app / --master で指定します。
このスクリプトの生成データには、記事の数字を再現するための仕掛けがあります。
- 乱数は種固定。 いつ・誰が実行しても同じ数量の列になります。現在庫などの残高・金額の数字は、この記事とあなたの環境で完全に一致します
- 日付は実行日基準。 「直近 1 年」に散らすので、相対日付・期間のペインがいつ実行しても動きます(月別の棒の並びだけ、実行月に応じてずれます)
- 在庫が負になりません。 薄くなったときだけ補充する動きを再現しているので、残高のグラフが実際の在庫らしい鋸歯になります
- 製品マスタは書き換えません。 パック標準の 8 品目をそのまま使います
投入後の残高(= このあと作る「現在庫」)は次のとおりです。下見モードでも同じ表が出るので、投入前に確認できます。
| 製品名 | 残高 | 製品名 | 残高 | |
|---|---|---|---|---|
| 食パン | 689 | ライ麦パン | 135 | |
| 牛乳 | 421 | 野菜ジュース | 95 | |
| トマト缶 | 438 | 緑茶 | 89 | |
| ほうじ茶 | 208 | バター | 73 |
数字がずれてきたら、もう一度
--apply --cleanで入れ直してください。 残高は変わりませんが、「在庫日数」など今日との距離を使う数字は日が経つと変わります。入れ直すと日付が実行日基準に張り直されて、記事の数字に戻ります。
まず結論:10 分で同じ画面を作る
手を動かす前に完成形を見たい方は、設定 JSON を取り込むだけで再現できます。
- 製品在庫管理アプリ → アプリの設定 → 一覧 で、形式がカスタマイズ(HTML は空のまま)の一覧を 2 枚作り、アプリを更新する(専用一覧の考え方は SFA 編参照)。一覧名は次の 2 つ。設定 JSON がこの名前で割り当て先を探すため、一字一句同じにしてください
【在庫】ダッシュボード【在庫】分析
- アプリの設定 → プラグイン → kSQL Dashboard Pro の 設定 を開く
- ヘッダーの ツール → インポート から
在庫管理パック-ダッシュボード.json(付録)を選択。確認ダイアログの「アプリ番号の変換」にAPP4228→ 製品在庫管理・APP4229→ 製品マスタ の番号を入れてインポート - 保存
インポート 1 回で 2 枚の一覧にそれぞれ専用のダッシュボードが自動で割り当たります。設定 JSON が「どの一覧に出すか」を一覧名で持っているためで、取り込み結果に「一覧名で割り当て 2 件」と表示されれば成功です。
あわせて共通のサマリー 1 段(在庫金額・総在庫数を 2 タイルで出す KPI + 読み方のメモ)も入ります。共通は個別設定を持たないすべての一覧のヘッダーに出るので、標準の一覧(全レコード・絞り込み一覧)でも在庫の現在値がひと目で見えます。表示するかどうかは一覧ごとに設定できます(後述)。高さを 1 段に抑えてあるのは、レコードの表を画面外へ押し出さないためです。タイトルを「(全体)」としているのは、一覧の絞り込みとは連動しない全社の数字だと開く前に分かるようにするため — このあたりの設計は SFA 編の「どこに何を置くか」が詳しいです。
**「割り当てられなかった一覧」と出たら、一覧名が JSON と一致していません。**手順 1 の名前を直してから再インポートするか、ツール → ビューへ取り込む(取り込み先をダイアログで選ぶ方式)で 1 枚ずつ入れてください。名前を変えて使いたい場合もビューへ取り込むが使えます。
【在庫】ダッシュボード は 3 タブ(在庫サマリー / 入出庫の推移 / 滞留・終売)、【在庫】分析 は在庫日数まで踏み込む 1 タブです。タブは表示中のタブだけを集計するので、ペインが増えても開く速さは変わりません。
以降は、このペインを 1 つずつ「なぜその SQL なのか」「どこを設定したのか」に分けて説明します。
在庫の集計は「残高型」— 最初に押さえる 1 つだけ
売上ダッシュボードの基本は「今月の売上」のように期間で切って足すことでした。在庫はここが根本から違います。
売上: 今月の売上 = 今月のレコードの合計 ← WHERE で期間を絞る
在庫: 現在庫 = 過去すべての入庫 − 出庫 ← WHERE で絞れない
現在庫は全履歴の合計そのものです。期間で絞った瞬間に「その期間の増減」に意味が変わってしまいます。だから在庫のペインは WHERE なしの全件集計が基本形になり、このダッシュボードの在庫サマリータブには期間のつまみがありません。
前提: 在庫アプリには棚卸や月末在庫の「確定情報」がありません。 製品在庫管理アプリが持っているのは入出庫の記録だけです。そのため本記事の在庫の数字(現在庫・月末在庫)はすべて、アプリにある期間の入出庫履歴を足し上げた計算値です。履歴より前から在庫があった場合、そのぶん一律にずれます(履歴の始まりを残高 0 とみなすため)。棚卸で実数を合わせる運用をする場合は、差分を入庫・出庫のレコードとして入れれば、計算値が実在庫に一致します。
「全件」と聞くと重そうですが、心配は要りません。同じ全件取得を使うペインが何枚あっても、取得は共有キャッシュにまとまります — 参照する列まで同じなら 1 回、ペインごとに列が違っても「最初の取得 + 列がそろう取り直し」の多くて 2 回です。1,000 件なら REST 2〜4 往復で、ペインの数だけ増えたりはしません。
もう 1 つ、在庫管理パックには気の利いた仕掛けがあります。製品在庫管理アプリの 個数_在庫計算用 という計算フィールドで、入庫なら +個数、出庫なら −個数が最初から入っています。つまり:
SELECT SUM(個数_在庫計算用) FROM APP4228 -- これだけで全製品の合計在庫
本編のダッシュボードはこのフィールドを使います(この計算フィールドが無いアプリでの書き方は、後半の分析ビューで扱います)。
共通サマリー:すべての一覧のヘッダーに 1 段
ペインの説明は、どの一覧でも最初に目に入る共通からです。共通に置いたペインは個別設定を持たないすべての一覧(全レコード・絞り込み一覧など)のヘッダーに出ます。表示するかどうかは一覧ごとに設定できます(既定は表示)。冒頭のスクリーンショット「在庫サマリー」がそれで、レコードの一覧を開いたついでに在庫の現在値が目に入ります。
KPI 複数行タイル(在庫金額と総在庫数を 1 枚で)
CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;
SELECT '在庫金額(円)' AS タイトル, SUM(z.在庫数 * m.仕入価格) AS メイン値
FROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名
UNION ALL
SELECT '総在庫数(個)', SUM(在庫数) FROM #z
実行結果(2 行 → 2 タイル)
| タイトル | メイン値 |
|---|---|
| 在庫金額(円) | 482,710 |
| 総在庫数(個) | 2,148 |
なぜこう書くのか
-
;区切りの複文です(1 文目が製品ごとの残高を一時テーブルに置く。仕組みはこの後のペイン 1 で説明します)。 - KPI カードは表示タブの 「各行をタイルで並べる」(
multi)を入れると、結果の行数ぶんタイルが並びます。1 段に 2 つの数字を出したいとき、ペインを 2 枚に割らずに済みます。 - 各行は
タイトル列がタイル見出し、メイン値列が数字になります(KPI の予約列名)。2 つの指標はUNION ALLで縦に積むだけです。 -
数値書式はペインに 1 つなので、
¥と個のような単位の違いは書式では出し分けられません。ここではタイル見出し側(「在庫金額(円)」)に単位を持たせています。
ペイン設定
| タブ | 項目 | 設定値 |
|---|---|---|
| 基本 | 表示タイプ | KPI カード |
| 表示 | 各行をタイルで並べる | ✅ |
| 表示 | 値のフォーマット | 3 桁区切り ✅ |
隣にはマークダウンのペインで「この数字の読み方」を置いています(付録の JSON 参照)。全履歴の現在値であって、いま見ている一覧の絞り込みとは連動しないこと、詳細はどの一覧にあるかを 2 行で書いてあります。
共通の運用ポイント(詳しくは SFA 編の「どこに何を置くか」)
- 高さは 1 段(2〜3 行)に抑える。 共通は標準の一覧にも出るので、高くするとレコードの表が画面の外へ押し出されます。
- タイトルに「(全体)」のような範囲を書く。 絞り込み済みの一覧の上に全社の数字が並ぶと、「この一覧の集計」と誤読されるためです。
- 一覧ごとに非表示にもできます。 設定画面の左の一覧で対象を選び、「このビューで表示」のチェックを外せば、その一覧には出ません(個人の作業リストなどに)。
専用一覧のダッシュボード(カスタマイズ一覧 + 個別設定)
ここからが本編の 2 枚 — 【在庫】ダッシュボード と 【在庫】分析 です。共通と違い、こちらはその一覧だけに出る「個別」の設定です。仕組みを押さえておきます。
- 置き場所はカスタマイズ形式の一覧。 カスタマイズ形式にはレコードの表が無いため、画面いっぱいをダッシュボードに使えます。共通のような高さの制約を気にせず、タブで何段でも組めます。
-
一覧ごとに「共通 / 個別」を切り替えられます。 設定画面の左側の一覧で対象を選ぶと、その一覧の設定欄が出ます。
共通(既定)のままなら共通サマリーが出て、個別に切り替えるとその一覧専用のペインを置けます。 -
この記事では、この切り替えをインポートが済ませています。 設定 JSON が 2 つの一覧を一覧名で見つけて
個別にし、ペイン一式を割り当てるので、手作業はありません。自分で 3 枚目を増やすときは「カスタマイズ形式の一覧を作る → 設定画面の左でその一覧を選ぶ →個別に切り替えてペインを追加(またはツール → ビューへ取り込む)」の順です。
以降のタブ 1〜3 は 【在庫】ダッシュボード の中身、その後の分析ビューは 【在庫】分析 の中身です。
タブ 1:在庫サマリー
ペイン 1:KPI カード(在庫金額)
一番目立たせたい数字は「いま棚に眠っているお金」です。
CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;
SELECT SUM(z.在庫数 * m.仕入価格) AS メイン値 FROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名
実行結果: ¥482,710
なぜこう書くのか
- 在庫金額は「製品ごとの在庫数 × 仕入価格」の合計です。在庫数は履歴(APP4228)から、単価はマスタ(APP4229)からなので、集計してから JOIN します。
-
;で区切った複文です。1 文目のCREATE TEMP TABLE #zが製品ごとの残高を一時テーブルに置き、2 文目がマスタと結合します。ペインは複文をそのまま実行でき、描画されるのは最後に結果を返した文です。 - 一時テーブルは閲覧者のブラウザのメモリ上だけの存在です。kintone 側には何も作られません。
ペイン設定
| タブ | 項目 | 設定値 |
|---|---|---|
| 基本 | 表示タイプ | KPI カード |
| 表示 | 値のフォーマット | 種別 数値 / 接頭辞 ¥ / 3 桁区切り ✅ |
ペイン 2:KPI カード(安全在庫割れ)+ 変数コントロール
「在庫が少ない製品がいくつあるか」。ここで問題がひとつ — 在庫管理パックには「安全在庫」のフィールドがありません。しきい値をアプリに持てないので、**ダッシュボード側のつまみ(変数コントロール)**にします。
CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;
SELECT COUNT(*) AS メイン値 FROM #z WHERE 在庫数 < @safe
実行結果(@safe = 既定の 100): 3 品目(バター 73・緑茶 89・野菜ジュース 95)
なぜこう書くのか
-
@safeは変数コントロールの値です。タブの設定で「変数コントロール」を追加し、変数名safe・種別数値・既定値100にすると、ダッシュボードのバーに数値入力が現れ、閲覧者がその場でしきい値を動かせます。値を変えると、@safeを使うペインだけが再計算されます。 - 「しきい値をどこに持つか」は設計の分かれ道です。本来はマスタに安全在庫フィールドを足すのが筋ですが、アプリを改造せずに試せるのが変数コントロールの良さです。運用が固まったらフィールド化し、
WHERE 在庫数 < m.安全在庫に書き換えるとよいでしょう。
ペイン設定
| タブ | 項目 | 設定値 |
|---|---|---|
| 基本 | 表示タイプ | KPI カード |
| 表示 | 値のフォーマット | 接尾辞 品目
|
| 表示 | 条件付き書式 | 値が 1 以上 なら赤字(0 のときだけ平常色) |
| タブの右クリック → コントロール | 追加 | 種別 数値 / 変数名 safe / ラベル 安全在庫の目安 / 既定値 100
|
ペイン 3:KPI カード(総在庫数)
SELECT SUM(個数_在庫計算用) AS メイン値 FROM APP4228
実行結果: 2,148 個
計算フィールドのおかげで 1 行です。JOIN も一時テーブルも要りません。フォーマットは接尾辞 個 + 3 桁区切り。
ペイン 4:表(製品別の在庫 — 要発注の行を赤く)
サマリータブの主役です。残高・在庫金額・判定を 1 表にします。
CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数, MAX(日付) AS 最終取引 FROM APP4228 GROUP BY 製品名;
SELECT m.製品名, m.分類, m.生産状況, z.在庫数, m.仕入価格, z.在庫数 * m.仕入価格 AS 在庫金額,
CASE WHEN z.在庫数 < @safe THEN '要発注' ELSE '' END AS 判定, z.最終取引
FROM APP4229 m LEFT JOIN #z z ON m.製品名 = z.製品名
ORDER BY z.在庫数
実行結果(在庫数の少ない順)
| 製品名 | 分類 | 生産状況 | 在庫数 | 仕入価格 | 在庫金額 | 判定 | 最終取引 |
|---|---|---|---|---|---|---|---|
| バター | 食品 | 生産可能 | 73 | ¥400 | ¥29,200 | 要発注 | 2026-08-01 |
| 緑茶 | 飲料 | 生産可能 | 89 | ¥120 | ¥10,680 | 要発注 | 2026-08-07 |
| 野菜ジュース | 飲料 | 生産可能 | 95 | ¥190 | ¥18,050 | 要発注 | 2026-08-09 |
| ライ麦パン | 食品 | 生産終了 | 135 | ¥250 | ¥33,750 | 2025-12-26 | |
| ほうじ茶 | 飲料 | 生産終了 | 208 | ¥130 | ¥27,040 | 2026-02-26 | |
| 牛乳 | 飲料 | 生産可能 | 421 | ¥350 | ¥147,350 | 2026-08-07 | |
| トマト缶 | 食品 | 生産可能 | 438 | ¥180 | ¥78,840 | 2026-08-08 | |
| 食パン | 食品 | 生産可能 | 689 | ¥200 | ¥137,800 | 2026-08-08 | |
| 合計 | 2,148 | ¥482,710 |
(「合計」の行は SQL ではなくペインの集計行機能です。)
なぜこう書くのか
-
マスタを主(
FROM APP4229 m)にしてLEFT JOINします。履歴側を主にすると「一度も入出庫していない製品」が表から消えます。マスタ主なら、取引ゼロの製品は在庫数が空欄のまま行に残り、「載っていない」ではなく「動いていない」と読めます。 -
結合キーは
製品名。 マスタの製品名は値の重複を禁止したフィールドです。実は在庫管理パックには製品番号もありますが、標準サンプルでは番号が重複しています。重複したキーで JOIN すると、エラーにならずに 2 製品の履歴が混ざって数字が壊れます。JOIN のキーは必ず重複禁止のフィールドを選んでください。 - 判定列はペイン 2 と同じ
@safeを参照します。バーのつまみを動かすと、KPI と表の「要発注」が同時に変わります。
ペイン設定
| タブ | 項目 | 設定値 |
|---|---|---|
| 基本 | 表示タイプ | 表 |
| 表示 | 表示密度 | コンパクト |
| 表示 | 列の設定… → 在庫数 | 揃え 右 / 3 桁区切り / 集計 合計
|
| 表示 | 列の設定… → 仕入価格・在庫金額 | 揃え 右 / 接頭辞 ¥・3 桁区切り(在庫金額は集計 合計も) |
| 表示 | 条件付き書式 | ①ヒートマップ / 列 在庫数 / 配色 赤 / 反転 ✅(少ないほど濃い) ②しきい値 / 判定 = 要発注 なら行を赤系に |
ペイン 5:ドーナツ(分類別の在庫金額)
CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;
SELECT m.分類, SUM(z.在庫数 * m.仕入価格) AS 在庫金額
FROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名
GROUP BY m.分類 ORDER BY 在庫金額 DESC
実行結果: 食品 ¥279,590 / 飲料 ¥203,120
ペイン 1 と同じ一時テーブルを分類で切っただけです。ここで大事なのは中身より取得回数の方 — ペイン 1・2・4・5 はどれも「APP4228 を全件」で始まりますが、押し下げ後のクエリが同じなので取得は共有されます(参照する列が違う分も、和集合の取り直しが 1 回入るだけでペイン数ぶんは増えません)。ペインを増やすことをためらわなくてよい、というのが設計上の答えです。
タブの右端にはマークダウンのペインで「この画面の読み方」を置いてあります(付録の JSON 参照)。数字の定義(在庫金額 = 在庫数 × 仕入価格)や、つまみの意味を書いておくと、作った本人以外も安心して使えます。
タブ 2:入出庫の推移
このタブだけは「期間で切って足す」、つまり売上ダッシュボードと同じ系統です。バーに期間コントロール(対象期間)を置き、KPI と明細がそれに追従します。
ペイン 6:棒グラフ(月次の入出庫 — 全期間)
SELECT DATE_FORMAT(日付, '%Y-%m') AS 年月,
SUM(CASE WHEN 入出庫区分 = '入庫' THEN 個数 ELSE 0 END) AS 入庫,
SUM(CASE WHEN 入出庫区分 = '出庫' THEN 個数 ELSE 0 END) AS 出庫
FROM APP4228 GROUP BY 年月 ORDER BY 年月
実行結果(抜粋。直近 1 年の 13 か月分が出ます)
| 年月 | 入庫 | 出庫 |
|---|---|---|
| 2026-05 | 2,009 | 3,391 |
| 2026-06 | 3,890 | 3,249 |
| 2026-07 | 3,137 | 3,763 |
| 2026-08 | 1,056 | 685 |
なぜこう書くのか
-
SUM(CASE …)で「入庫」「出庫」を列に分けています(ピボット)。棒・折れ線は数値の列がそのまま系列になるので、この形にすると月ごとに入庫と出庫の 2 本が並びます。 -
SELECT 年月, 入出庫区分, SUM(個数) … GROUP BY 年月, 入出庫区分という**縦持ち(3 列)**でも同じ数字は出ますが、素の棒グラフはこれを系列に分けられず、同じ月のラベルが 2 回並ぶ壊れた見た目になります。縦持ちを使うのは積み上げ棒(mappingの指定が要る)のときです。迷ったらSUM(CASE)で横持ちが簡単です。 - このペインは推移を見るのが目的なので、意図的に期間で絞っていません。期間コントロールに追従するのは次の KPI と明細だけです。
ペイン設定
| タブ | 項目 | 設定値 |
|---|---|---|
| 基本 | 表示タイプ | 棒 |
| 表示 | 凡例の位置 | 下 |
| 表示 | 値のフォーマット | 3 桁区切り / 接尾辞 個
|
ペイン 7〜9:KPI カード × 3(期間の出庫・入庫・取引件数)
SELECT SUM(個数) AS メイン値 FROM APP4228
WHERE 入出庫区分 in ('出庫') AND 日付 >= @period_from AND 日付 < @period_to_next
実行結果(期間 = 今月、2026-08 のデータ): 出庫 685 個 / 入庫 1,056 個 / 取引 15 件
(入庫は in ('入庫') に、取引件数は WHERE の区分条件を外して COUNT(*) に変えるだけです。)
なぜこう書くのか — 半開区間
日付の絞り込みには 2 通りの書き方があり、**選ぶ基準は「期間指定(任意の開始〜終了)に追従させたいか」**です。
| 書き方 | プリセット(今月・先月…) | 期間指定(任意区間) |
|---|---|---|
日付 = @period |
○ | × |
日付 >= @period_from AND 日付 < @period_to_next |
○ | ○ |
単一変数形(= @period)には kintone の相対日付関数がそのまま入るため速くて簡単ですが、任意の開始〜終了は kintone の関数では表せません。期間指定を選んでも既定のプリセットで動き、ペインに注意書きが出ます。任意区間まで使いたいペインは**範囲形(半開区間)**で書きます。
-
_toではなく_to_next(終了日の翌日)を使い、<で閉じます。<= @period_toは日時フィールドで終了日の日中を取りこぼします。 - 1 つのタブでは書き方を揃えてください。 混ぜると押し下げ後のクエリが変わり、共有キャッシュのキーが割れて取得回数が増えます。
ペイン設定(3 枚共通)
| タブ | 項目 | 設定値 |
|---|---|---|
| 基本 | 表示タイプ | KPI カード |
| 表示 | 値のフォーマット | 3 桁区切り + 接尾辞( 個 / 件) |
| タブの右クリック → コントロール | 追加 | 種別 期間 / 変数名 period / ラベル 対象期間 / 既定 今月 / プリセット 今月・先月・今年・去年・期間指定 |
ペイン 10:明細表(期間の入出庫明細)
SELECT $id, 日付, 製品名, 入出庫区分, 個数, 仕入先
FROM APP4228 WHERE 日付 >= @period_from AND 日付 < @period_to_next
ORDER BY 日付 DESC, $id DESC
$id を SELECT に入れると各行の左端にレコードを開くアイコンが出ます。条件付き書式で 入出庫区分 = 入庫 の行を緑系にすると、明細の中で入庫がぱっと拾えます。表示は行番号 ✅・表示密度 コンパクト。
タブ 3:滞留・終売
ペイン 11:表(動きの止まった製品)
在庫が多い製品が問題とは限りません。 よく売れるから多めに持っているのかもしれない。効くのは最終取引がいつかです。
CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数, MAX(日付) AS 最終取引 FROM APP4228 GROUP BY 製品名;
SELECT z.最終取引, m.製品名, m.生産状況, z.在庫数, z.在庫数 * m.仕入価格 AS 滞留金額
FROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名
ORDER BY z.最終取引
実行結果(最終取引の古い順・上位)
| 最終取引 | 製品名 | 生産状況 | 在庫数 | 滞留金額 |
|---|---|---|---|---|
| 2025-12-26 | ライ麦パン | 生産終了 | 135 | ¥33,750 |
| 2026-02-26 | ほうじ茶 | 生産終了 | 208 | ¥27,040 |
| 2026-08-01 | バター | 生産可能 | 73 | ¥29,200 |
なぜこう書くのか
-
MAX(日付)が「その製品が最後に動いた日」です。古い順に並べると、上に来るのが棚に置きっぱなしの候補になります。 - このデータでは生産終了の 2 品(ライ麦パン・ほうじ茶)が半年前から止まっていて、合わせて ¥60,790 が眠っています。条件付き書式で
生産状況 = 生産終了の行を黄色にし、滞留金額にヒートマップを掛けると、この 2 行が目に飛び込みます。
分析ビュー:在庫日数まで踏み込む
【在庫】分析 は同じデータをもう一段深く見ます。ここでは 2 つの新しい話が出てきます。
計算フィールドが無くても書ける(SUM(CASE))
ここまでのタブはパックの計算フィールド 個数_在庫計算用 に頼っていました。自作アプリにはこのフィールドは無いので、分析ビューの SQL は符号を SQL 側で付けています。
WITH s AS (
SELECT 製品名, SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS 現在庫
FROM APP4228 GROUP BY 製品名
)
SELECT SUM(s.現在庫 * m.仕入価格) AS メイン値
FROM APP4229 m
INNER JOIN s ON m.製品名 = s.製品名
実行結果: ¥482,710 — タブ 1 のペイン 1 と同じ値です。SUM(個数_在庫計算用) と SUM(CASE WHEN 出庫 THEN -個数 ELSE 個数 END) は等価で、どちらの書き方でも残高が出せることの実地確認になっています。この形なら入出庫の 2 値を持つどんなアプリにも移植できます。
KPI はあと 2 枚 — **滞留在庫金額(生産終了品)**は上の SQL に WHERE m.生産状況 = '生産終了' を足すだけで ¥60,790。総在庫数は SELECT SUM(CASE …) FROM APP4228 だけで 2,148 個です。
ペイン 12:表(製品別サマリ — 現在庫・在庫日数・判定)
「あと何日もつか」。現在庫を直近 90 日の出庫ペースで割ります。
DECLARE @d90 = DATE_ADD(CURRENT_DATE(), -89, 'DAY');
WITH s AS (
SELECT 製品名, SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS 現在庫
FROM APP4228 GROUP BY 製品名
), d AS (
SELECT 製品名, SUM(個数) AS 出庫90
FROM APP4228
WHERE 入出庫区分 = '出庫' AND 日付 >= @d90
GROUP BY 製品名
)
SELECT m.製品名, m.分類, m.生産状況, s.現在庫,
s.現在庫 * m.仕入価格 AS 在庫金額,
CASE WHEN d.出庫90 = '' THEN '' ELSE ROUND(s.現在庫 * 90 / d.出庫90, 1) END AS 在庫日数,
CASE WHEN d.出庫90 = '' THEN '滞留'
WHEN s.現在庫 * 90 / d.出庫90 < 14 THEN '要補充'
ELSE '' END AS 判定
FROM APP4229 m
LEFT JOIN s ON m.製品名 = s.製品名
LEFT JOIN d ON m.製品名 = d.製品名
ORDER BY 在庫金額 DESC
実行結果(データ投入直後)
| 製品名 | 分類 | 生産状況 | 現在庫 | 在庫金額 | 在庫日数 | 判定 |
|---|---|---|---|---|---|---|
| 牛乳 | 飲料 | 生産可能 | 421 | ¥147,350 | 10.8 日 | 要補充 |
| 食パン | 食品 | 生産可能 | 689 | ¥137,800 | 19.3 日 | |
| トマト缶 | 食品 | 生産可能 | 438 | ¥78,840 | 34.4 日 | |
| ライ麦パン | 食品 | 生産終了 | 135 | ¥33,750 | 滞留 | |
| バター | 食品 | 生産可能 | 73 | ¥29,200 | 11.8 日 | 要補充 |
| ほうじ茶 | 飲料 | 生産終了 | 208 | ¥27,040 | 滞留 | |
| 野菜ジュース | 飲料 | 生産可能 | 95 | ¥18,050 | 14 日 | 要補充 |
| 緑茶 | 飲料 | 生産可能 | 89 | ¥10,680 | 9 日 | 要補充 |
なぜこう書くのか
- 「全履歴の集計(現在庫)」と「期間の集計(出庫 90 日)」は別の CTE に分けます。1 つの SELECT に混ぜると WHERE がどちらかを壊します。
-
CASE WHEN 出庫90 = '' THEN ''のガードは省略できません。 90 日間出庫の無い製品は LEFT JOIN で出庫90が空文字になり、そのまま割ると結果列にNaNが表示されます。ガードすれば空欄になり、判定列がそれを「滞留」と名前を付けます。「計算できない」は在庫の文脈では「動いていない」という情報です。 - 表の在庫数の多い順ではなく在庫金額の多い順に並べています。発注の優先度はお金で決まるからです。
- 細かい話: 野菜ジュースは表示が「14 日」なのに要補充です。表示は小数 1 桁への丸め(実際は 13.9x 日)、判定は丸め前の値で
< 14を評価しているためです。表示の丸めと判定は別物、と覚えてください。
ペイン設定は タブ 1 ペイン 4 と同系です。条件付き書式は 判定 = 要補充 → 行を赤系、判定 = 滞留 → 行を黄系、在庫金額 にヒートマップ。
ペイン 13:複合グラフ(月次の純増減と月末在庫)
在庫の推移は「月ごとの増減」と「その累積 = 月末在庫」を 1 枚で見ます。
WITH mo AS (
SELECT DATE_FORMAT(日付, '%Y-%m') AS 年月,
SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS 純増減
FROM APP4228 GROUP BY 年月
)
SELECT 年月, 純増減,
SUM(純増減) OVER (ORDER BY 年月 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS 月末在庫
FROM mo ORDER BY 年月
実行結果(抜粋)
| 年月 | 純増減 | 月末在庫 |
|---|---|---|
| 2026-05 | -1,382 | 1,762 |
| 2026-06 | +641 | 2,403 |
| 2026-07 | -626 | 1,777 |
| 2026-08 | +371 | 2,148 |
最終月の月末在庫が総在庫数 2,148 と一致します。累積和が残高の定義そのものだからです。
なぜこう書くのか
- 累積和はフレーム付きの集計ウィンドウ
SUM() OVER (ORDER BY … ROWS …)で作ります。集計と同じ SELECT には書けないので、CTE で段を分けます。 -
フレームは
ROWSを明示します。 既定(RANGE)は同値キーをまとめて足すため、キーに重複があると累積が変わります。累積和は常にROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWと書く癖をつけてください。 - 月末在庫は「履歴開始を残高 0 とした帳簿値」です。 履歴より前から在庫があった場合は全期間が一律に過小になります。正しくするには期首在庫(棚卸)を履歴の先頭に 1 レコード入れます。
- 純増減は負になり得ます。棒が 0 の下へ伸びるのは正常です。
ペイン設定: 表示タイプ 複合、棒 = 純増減・線 = 月末在庫(表示タブの bars / lines 割り当て)。
つまずきポイント(実際に出る症状と対処)
JOIN したら数字が大きすぎる
結合キーに重複のあるフィールドを使っています。エラーは出ません。重複したキーの行どうしが掛け合わさって、履歴が二重に数えられます。在庫管理パックなら 製品番号 が該当(標準サンプルで重複あり)。重複禁止の 製品名 で JOIN してください。
表に NaN が出る
出庫ゼロの製品の在庫日数など、空文字での割り算です。kSQL は空の数値セルを算術で 0 に読み替えますが、0 で割った結果は NaN としてそのまま表示されます。CASE WHEN 分母 = '' THEN '' ELSE … END でガードします(分母が空文字で返るのは LEFT JOIN の不一致行)。
累積和が階段状にまとまる
SUM() OVER (ORDER BY …) にフレームを書いていません。既定の RANGE は同値キーをまとめて足すため、同じ値の行が同時に跳ねます。ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW を明示します。
期間指定(任意区間)にペインが追従しない
日付 = @period の単一変数形です。プリセット(今月・先月…)では動きますが、任意区間は kintone の関数で表せないため追従しません。日付 >= @period_from AND 日付 < @period_to_next の半開区間に書き換えます。
月別の棒グラフで同じ月が 2 本出る
GROUP BY 年月, 区分 の縦持ち(3 列)を素の棒に渡しています。素の棒・折れ線は数値列 = 系列のワイド形式です。SUM(CASE WHEN 区分 = '…' THEN 個数 ELSE 0 END) で列に分けます(ペイン 6 参照)。
データが増えたら突然エラーになった
残高型は全履歴が入力で、WHERE では減らせません。履歴が取得上限(既定 10,000 件)を超えたら SQL 側に対処はありません — 期首残高(棚卸)レコードを入れて古い履歴を締める運用・アプリ設計で区切ります。日次の入出庫が 30 件でも 1 年で約 1 万件になります。「締め」は最初から運用に入れておくのがおすすめです。
まとめ
- 在庫は残高型 — 現在値 = 全履歴の合計。WHERE で期間を絞らないのが基本形
- パックの計算フィールド(
個数_在庫計算用)があればSUM()1 発、無いアプリでもSUM(CASE)で同じ残高が出せる - 「集計してから JOIN」を複文 + 一時テーブル(または CTE)で書く。マスタ主の LEFT JOIN で「動いていない製品」も表に残す
- しきい値は変数コントロールでつまみに、期間は半開区間で期間コントロールに追従させる
- 滞留は在庫数ではなく最終取引日で見つける。在庫日数は表示の丸めと判定のずれに注意
- 残高型は取得上限に必ず近づいていく。期首在庫(棚卸)で履歴を締める運用をセットで
関連記事
- kSQL Dashboard Pro 製品紹介 — 表示タイプ・設定画面・テーマなど機能全般
- SFA パック編 — 期間集計が主役のダッシュボード。基本の 3 ステップ・一覧の使い分けはこちらが詳しいです
付録 — 配布ファイル(コピーして保存)
本文で使う設定 JSON とスクリプトの全文です。使い方は 3 手順です。
- 折りたたみを開き、コードブロック右上のコピーボタンでコピー
- テキストエディター(メモ帳等)に貼り付け、見出しのファイル名どおりに保存(文字コード UTF-8)
- 設定画面の ツール → インポート でそのファイルを選択(スクリプトは
nodeで実行)
SQL 中の
APP4228/APP4229は、取り込み時の確認ダイアログ「アプリ番号の変換」でご自身の環境の番号へ変換できます(JSON を手で書き換える必要はありません)。
load-stock-data.mjs — 検証データ 1,000 件を投入(最初に実行)
#!/usr/bin/env node
/**
* 在庫管理パックの「製品在庫管理」アプリに、検証用の入出庫データ 1,000 件を投入する。
*
* アプリストアの在庫管理パックのサンプルデータは 12 件しかなく、日付も過去で
* 固定のため、残高(現在庫)や相対日付のペインがほとんど動きません。
* このスクリプトは**実行日までの 1 年**に散らした入出庫 1,000 件を作って投入します。
*
* - 乱数は種固定。**いつ・何度実行しても同じ数量列**になり、現在庫などの
* 残高は記事の数字と一致します(日付だけが実行日基準でずれます)
* - 製品ごとに在庫が**負にならない**よう、薄くなったときだけ補充します
* - 製品マスタ(8 品目)はパック標準のまま使います。書き換えません
*
* node load-stock-data.mjs 下見(生成内容の要約のみ。通信しない)
* node load-stock-data.mjs --apply 投入を実行する
* node load-stock-data.mjs --apply --clean 既存レコードを全削除してから投入
*
* 環境変数:
* KINTONE_BASE_URL https://xxxxx.cybozu.com
* KINTONE_USERNAME / KINTONE_PASSWORD または KINTONE_API_TOKEN
* (API トークンを使う場合は、対象アプリで「レコード閲覧・追加・削除」を許可)
*
* 引数:
* --app 4228 製品在庫管理のアプリ番号
* --master 4229 製品マスタのアプリ番号(仕入先の転記に読むだけ)
* --end 2026-08-10 日付の終端を固定する(過去のデータを正確に再現したいときのみ)
* --apply 書き込みを実行する
* --clean 投入前に既存レコードをすべて削除する(--apply のときだけ働く)
*
* Node 18 以上、依存パッケージなし。
*/
const args = process.argv.slice(2);
const flag = (name) => args.includes(`--${name}`);
const value = (name, fallback) => {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
};
const APP = value("app", "4228");
const MASTER = value("master", "4229");
const APPLY = flag("apply");
const CLEAN = flag("clean");
/* ---- データ生成(種固定) ---------------------------------------------- */
const TOTAL = 1000;
const endArg = value("end", null);
const END = endArg
? new Date(`${endArg}T00:00:00Z`)
: new Date(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
const DAYS = 365;
let seed = 20260805;
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
const pick = (n) => Math.floor(rnd() * n);
/** 製品ごとの性格。weight = 件数の配分、out = 1 回の出庫の目安、in = 1 回の入庫の目安 */
const PRODUCTS = [
{ name: "食パン", weight: 22, out: [30, 90], in: [400, 700], stopAfter: null },
{ name: "牛乳", weight: 20, out: [40, 120], in: [500, 900], stopAfter: null },
{ name: "トマト缶", weight: 14, out: [20, 60], in: [300, 500], stopAfter: null },
{ name: "緑茶", weight: 13, out: [15, 50], in: [250, 450], stopAfter: null },
{ name: "バター", weight: 11, out: [10, 40], in: [200, 350], stopAfter: null },
{ name: "野菜ジュース", weight: 10, out: [15, 45], in: [250, 400], stopAfter: null },
// 生産終了の 2 品は**途中で動きが止まる**。滞留在庫として残高が残る
{ name: "ほうじ茶", weight: 6, out: [10, 30], in: [150, 250], stopAfter: 0.55 },
{ name: "ライ麦パン", weight: 4, out: [10, 25], in: [120, 200], stopAfter: 0.4 },
];
const ymd = (offsetDays) => {
const d = new Date(END.getTime() - offsetDays * 86400000);
return d.toISOString().slice(0, 10);
};
function generate() {
const rows = [];
const totalWeight = PRODUCTS.reduce((s, p) => s + p.weight, 0);
for (const p of PRODUCTS) {
const count = Math.max(4, Math.round((p.weight / totalWeight) * TOTAL));
/* 期間内に散らす位置(0 = 1 年前、1 = 今日)。生産終了品は stopAfter で打ち切る */
const span = p.stopAfter ?? 1;
const positions = Array.from({ length: count }, () => rnd() * span).sort((a, b) => a - b);
let balance = 0;
for (let i = 0; i < positions.length; i++) {
const day = Math.round((1 - positions[i]) * DAYS);
/* **薄くなったときだけ補充する。** 気まぐれな入庫を混ぜると在庫が一方的に
積み上がり、残高のグラフが右肩上がりの直線になる。
補充点を割ったときだけ入庫すれば、実際の在庫らしい鋸歯になる */
const isIn = i === 0 || balance < p.out[1] * 1.5;
const range = isIn ? p.in : p.out;
let qty = range[0] + pick(range[1] - range[0] + 1);
if (!isIn) qty = Math.min(qty, balance); // 在庫を超える出庫は作らない
if (!isIn && qty <= 0) continue;
balance += isIn ? qty : -qty;
rows.push({ date: ymd(day), name: p.name, kind: isIn ? "入庫" : "出庫", qty });
}
}
rows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
return rows.slice(0, TOTAL);
}
/* ---- kintone REST ------------------------------------------------------- */
function fail(msg) {
console.error(`エラー: ${msg}`);
process.exit(1);
}
function auth() {
const base = process.env.KINTONE_BASE_URL;
if (!base) fail("環境変数 KINTONE_BASE_URL を設定してください");
const headers = { "Content-Type": "application/json" };
if (process.env.KINTONE_API_TOKEN) {
headers["X-Cybozu-API-Token"] = process.env.KINTONE_API_TOKEN;
} else if (process.env.KINTONE_USERNAME && process.env.KINTONE_PASSWORD) {
headers["X-Cybozu-Authorization"] = Buffer.from(
`${process.env.KINTONE_USERNAME}:${process.env.KINTONE_PASSWORD}`,
).toString("base64");
} else {
fail("KINTONE_USERNAME / KINTONE_PASSWORD か KINTONE_API_TOKEN を設定してください");
}
return { base: base.replace(/\/$/, ""), headers };
}
async function request(conn, method, path, body) {
const res = await fetch(`${conn.base}${path}`, {
method,
headers: conn.headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
fail(`${method} ${path} が ${res.status} で失敗しました: ${text}`);
}
return res.json();
}
/** マスタから 製品名 → 仕入先 の対応を読む(ルックアップのコピー先を自前で埋めるため) */
async function fetchSuppliers(conn) {
const data = await request(
conn, "GET",
`/k/v1/records.json?app=${MASTER}&query=${encodeURIComponent("order by $id asc limit 500")}` +
`&fields[0]=${encodeURIComponent("製品名")}&fields[1]=${encodeURIComponent("仕入先")}`,
);
const map = new Map();
for (const r of data.records) map.set(r.製品名.value, r.仕入先.value);
return map;
}
async function fetchAllIds(conn) {
const ids = [];
for (;;) {
const query = `$id > ${ids.length ? ids[ids.length - 1] : 0} order by $id asc limit 500`;
const data = await request(
conn, "GET",
`/k/v1/records.json?app=${APP}&query=${encodeURIComponent(query)}&fields[0]=$id`,
);
if (data.records.length === 0) return ids;
for (const r of data.records) ids.push(Number(r.$id.value));
}
}
async function deleteAll(conn) {
const ids = await fetchAllIds(conn);
for (let i = 0; i < ids.length; i += 100) {
await request(conn, "DELETE", `/k/v1/records.json`, { app: Number(APP), ids: ids.slice(i, i + 100) });
}
return ids.length;
}
async function insertAll(conn, rows, suppliers) {
for (let i = 0; i < rows.length; i += 100) {
const records = rows.slice(i, i + 100).map((r) => ({
日付: { value: r.date },
製品名: { value: r.name },
入出庫区分: { value: r.kind },
個数: { value: String(r.qty) },
仕入先: { value: suppliers.get(r.name) ?? "" },
}));
await request(conn, "POST", `/k/v1/records.json`, { app: Number(APP), records });
process.stdout.write(` 投入 ${Math.min(i + 100, rows.length)}/${rows.length}\r`);
}
process.stdout.write("\n");
}
/* ---- 実行 ---------------------------------------------------------------- */
const rows = generate();
/* 検算: 製品ごとの残高が負でないこと */
const bal = {};
for (const r of rows) bal[r.name] = (bal[r.name] ?? 0) + (r.kind === "入庫" ? r.qty : -r.qty);
const bad = Object.entries(bal).filter(([, v]) => v < 0);
console.log(`件数: ${rows.length}`);
console.log(`期間: ${rows[0].date} 〜 ${rows[rows.length - 1].date}`);
console.log("残高:", Object.entries(bal).map(([k, v]) => `${k}=${v}`).join(" "));
console.log(bad.length ? `!! 負の残高: ${JSON.stringify(bad)}` : "残高はすべて 0 以上");
if (!APPLY) {
console.log("\n下見モードです(何も書き込んでいません)。投入するには --apply を付けてください。");
process.exit(0);
}
const conn = auth();
const suppliers = await fetchSuppliers(conn);
const missing = [...new Set(rows.map((r) => r.name))].filter((n) => !suppliers.has(n));
if (missing.length) fail(`製品マスタに次の製品がありません: ${missing.join(", ")}(--master の番号を確認してください)`);
if (CLEAN) {
const n = await deleteAll(conn);
console.log(`既存レコードを ${n} 件削除しました`);
}
await insertAll(conn, rows, suppliers);
console.log(`APP${APP} へ ${rows.length} 件を投入しました`);
在庫管理パック-ダッシュボード.json — 共通サマリー 1 段 + ダッシュボード(3 タブ 14 ペイン)+ 分析ビュー(インポートで一覧名に自動割り当て)
{
"date": "2026-08-10 18:00:00",
"pluginName": "kSQL Dashboard Pro",
"pluginVersion": "1",
"engineVersion": "3.66.0",
"appId": 4228,
"appName": "製品在庫管理(在庫管理パック)",
"sqlApps": [
{
"appId": 4228,
"appName": "製品在庫管理(在庫管理パック)"
},
{
"appId": 4229,
"appName": "製品マスタ(在庫管理パック)"
}
],
"config": {
"schemaVersion": 2,
"edition": "pro",
"common": {
"view": {
"name": "在庫サマリー",
"layout": [
{
"i": "c1",
"x": 0,
"y": 0,
"w": 30,
"h": 3
},
{
"i": "c2",
"x": 30,
"y": 0,
"w": 30,
"h": 3
}
],
"panes": [
{
"id": "c1",
"type": "kpi",
"title": "在庫サマリー(全体)",
"sql": "CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;\nSELECT '在庫金額(円)' AS タイトル, SUM(z.在庫数 * m.仕入価格) AS メイン値\nFROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名\nUNION ALL\nSELECT '総在庫数(個)', SUM(在庫数) FROM #z",
"options": {
"multi": true,
"format": {
"kind": "number",
"thousandSep": true
}
}
},
{
"id": "c2",
"type": "markdown",
"title": "この数字の読み方",
"sql": "",
"options": {
"markdown": "全履歴から計算した現在値です。**この一覧の絞り込みとは連動しません。**\n内訳・推移・滞留は一覧 **【在庫】ダッシュボード**、在庫日数の評価は **【在庫】分析** へ。"
}
}
]
}
},
"views": {
"1": {
"name": "在庫ダッシュボード",
"layout": [],
"panes": [],
"tabs": [
{
"id": "t1",
"name": "在庫サマリー",
"layout": [
{
"i": "p1",
"x": 0,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "p2",
"x": 20,
"y": 0,
"w": 19,
"h": 2
},
{
"i": "p3",
"x": 40,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "p4",
"x": 0,
"y": 2,
"w": 40,
"h": 6
},
{
"i": "p5",
"x": 40,
"y": 2,
"w": 20,
"h": 6
},
{
"i": "p6",
"x": 0,
"y": 8,
"w": 60,
"h": 6
}
],
"panes": [
{
"id": "p1",
"type": "kpi",
"title": "在庫金額",
"description": "在庫数 × 仕入価格の合計。**残高は時点の値**なので、上部の対象期間では変わりません。",
"sql": "CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;\nSELECT SUM(z.在庫数 * m.仕入価格) AS メイン値 FROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名",
"options": {
"format": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
}
}
},
{
"id": "p2",
"type": "kpi",
"title": "安全在庫割れ",
"description": "在庫数が**安全在庫の目安**を下回った製品の数。目安はバーの数値コントロールで変えられます。",
"sql": "CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;\nSELECT COUNT(*) AS メイン値 FROM #z WHERE 在庫数 < @safe",
"options": {
"format": {
"kind": "number",
"suffix": " 品目"
},
"rules": [
{
"target": "value",
"op": ">=",
"value": 1,
"tone": "crit",
"apply": "text"
}
]
}
},
{
"id": "p3",
"type": "kpi",
"title": "総在庫数",
"description": "入庫 − 出庫の合計。**符号はパックの計算フィールド**(個数(在庫計算用))が持っているので、SQL は SUM だけで済みます。",
"sql": "SELECT SUM(個数_在庫計算用) AS メイン値 FROM APP4228",
"options": {
"format": {
"kind": "number",
"suffix": " 個",
"thousandSep": true
}
}
},
{
"id": "p4",
"type": "table",
"title": "製品別の在庫",
"description": "**製品マスタを起点**にした一覧です。在庫数の色は少ないほど濃い赤(配色: 赤・反転)。判定が「要発注」の行は行全体を色分けします。",
"sql": "CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数, MAX(日付) AS 最終取引 FROM APP4228 GROUP BY 製品名;\nSELECT m.製品名, m.分類, m.生産状況, z.在庫数, m.仕入価格, z.在庫数 * m.仕入価格 AS 在庫金額,\n CASE WHEN z.在庫数 < @safe THEN '要発注' ELSE '' END AS 判定, z.最終取引\nFROM APP4229 m LEFT JOIN #z z ON m.製品名 = z.製品名\nORDER BY z.在庫数",
"options": {
"density": "compact",
"columns": {
"在庫数": {
"align": "right",
"format": {
"kind": "number",
"thousandSep": true
}
},
"仕入価格": {
"align": "right",
"format": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
}
},
"在庫金額": {
"align": "right",
"format": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
}
},
"判定": {
"align": "center"
}
},
"totals": {
"在庫数": "sum",
"在庫金額": "sum"
},
"rules": [
{
"kind": "heatmap",
"column": "在庫数",
"scheme": "red",
"reverse": true
},
{
"kind": "threshold",
"column": "判定",
"op": "=",
"value": "要発注",
"tone": "crit",
"scope": "row"
}
]
}
},
{
"id": "p5",
"type": "donut",
"title": "分類別の在庫金額",
"sql": "CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数 FROM APP4228 GROUP BY 製品名;\nSELECT m.分類, SUM(z.在庫数 * m.仕入価格) AS 在庫金額\nFROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名\nGROUP BY m.分類 ORDER BY 在庫金額 DESC",
"options": {
"legend": "bottom",
"valueFormat": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
}
}
},
{
"id": "p6",
"type": "markdown",
"title": "この画面の読み方",
"sql": "",
"options": {
"markdown": "### 在庫は「期間の集計」ではなく「残高」\n\n売上ダッシュボードは *期間で切って足す* のが基本ですが、在庫は違います。**入庫と出庫を全期間ぶん足した結果**が今の残高なので、期間で切る意味がありません。\n\nそのため**このタブのバーには「対象期間」を置いていません**。コントロールは全タブ共通とタブ専用の 2 層に置けるので、期間は **入出庫の推移** タブ専用にしてあります。効かないつまみを見せないための使い分けです。\n\n- **符号は kintone 側が持っています。** 製品在庫管理の `個数(在庫計算用)` は計算フィールドで、式は `IF(入出庫区分 = \"出庫\", -個数, 個数)`。だから SQL は `SUM(個数_在庫計算用)` だけで残高になります\n- **安全在庫のフィールドはパックにありません。** そこで目安を**数値コントロール**(バー上部)にしました。閲覧者が 50 / 100 / 200 と変えて、発注対象がどう変わるかをその場で試せます\n- **一覧は製品マスタが起点**(`LEFT JOIN`)。在庫履歴の無い製品も行として残るので、「登録したのに一度も動いていない品」が見つかります"
}
}
],
"controls": [
{
"var": "safe",
"type": "number",
"label": "安全在庫の目安",
"default": "100"
}
]
},
{
"id": "t2",
"name": "入出庫の推移",
"layout": [
{
"i": "q1",
"x": 0,
"y": 2,
"w": 60,
"h": 4
},
{
"i": "q2",
"x": 0,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "q3",
"x": 20,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "q4",
"x": 40,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "q5",
"x": 0,
"y": 6,
"w": 60,
"h": 7
},
{
"i": "q6",
"x": 0,
"y": 13,
"w": 60,
"h": 5
}
],
"panes": [
{
"id": "q1",
"type": "bar",
"title": "月次の入出庫(全期間)",
"description": "推移は**期間で切らない**ので、対象期間の影響を受けません。入庫が山、出庫が谷。",
"sql": "SELECT DATE_FORMAT(日付, '%Y-%m') AS 年月,\n SUM(CASE WHEN 入出庫区分 = '入庫' THEN 個数 ELSE 0 END) AS 入庫,\n SUM(CASE WHEN 入出庫区分 = '出庫' THEN 個数 ELSE 0 END) AS 出庫\nFROM APP4228 GROUP BY 年月 ORDER BY 年月",
"options": {
"legend": "bottom",
"valueFormat": {
"kind": "number",
"thousandSep": true,
"suffix": " 個"
}
}
},
{
"id": "q2",
"type": "kpi",
"title": "期間の出庫",
"sql": "SELECT SUM(個数) AS メイン値 FROM APP4228\nWHERE 入出庫区分 in ('出庫') AND 日付 >= @period_from AND 日付 < @period_to_next",
"options": {
"format": {
"kind": "number",
"suffix": " 個",
"thousandSep": true
}
}
},
{
"id": "q3",
"type": "kpi",
"title": "期間の入庫",
"sql": "SELECT SUM(個数) AS メイン値 FROM APP4228\nWHERE 入出庫区分 in ('入庫') AND 日付 >= @period_from AND 日付 < @period_to_next",
"options": {
"format": {
"kind": "number",
"suffix": " 個",
"thousandSep": true
}
}
},
{
"id": "q4",
"type": "kpi",
"title": "期間の取引件数",
"sql": "SELECT COUNT(*) AS メイン値 FROM APP4228\nWHERE 日付 >= @period_from AND 日付 < @period_to_next",
"options": {
"format": {
"kind": "number",
"suffix": " 件"
}
}
},
{
"id": "q5",
"type": "table",
"title": "期間の入出庫明細",
"description": "対象期間で絞った明細。**半開区間**(`>= @period_from AND < @period_to_next`)で書いてあるので、プリセットでも**期間指定(任意区間)**でも同じように絞り込めます。",
"sql": "SELECT $id, 日付, 製品名, 入出庫区分, 個数, 仕入先\nFROM APP4228 WHERE 日付 >= @period_from AND 日付 < @period_to_next\nORDER BY 日付 DESC, $id DESC",
"options": {
"density": "compact",
"rowNumbers": true,
"columns": {
"個数": {
"align": "right",
"format": {
"kind": "number",
"thousandSep": true
}
}
},
"rules": [
{
"kind": "threshold",
"column": "入出庫区分",
"op": "=",
"value": "入庫",
"tone": "good"
}
]
}
},
{
"id": "q6",
"type": "markdown",
"title": "期間の書き方",
"sql": "",
"options": {
"markdown": "### 期間指定に追従させたいなら **半開区間** で書く\n\n日付の絞り込みには 2 通りの書き方があります。**選ぶ基準は「期間指定(任意区間)に追従させたいか」** です。\n\n| 書き方 | プリセット | 期間指定 |\n| :--- | :--- | :--- |\n| `日付 = @period` | ○ kintone の相対日付関数がそのまま入る | **×** 既定プリセットで動く |\n| `日付 >= @period_from AND 日付 < @period_to_next` | ○ | **○** |\n\n任意の開始〜終了は kintone の関数では表せないため、単一変数形のペインは期間指定を選んでも**既定のプリセットで動きます**(ペインに注意書きが出ます)。このタブは全ペインを範囲形へ揃えました。\n\n- **`_to` ではなく `_to_next`(終了日の翌日)を使い、`<` で閉じる。** `<= @period_to` にすると、日時フィールドで終了日の日中を取りこぼします\n- **1 つのタブでは書き方を揃える。** 混ぜると押し下げ後のクエリが変わり、共有キャッシュのキーが割れて取得が増えます\n- 上の **月次の入出庫** は推移を見るペインなので、意図的に期間で絞っていません\n- **このコントロールはこのタブ専用です。** 在庫サマリー・滞留のタブは期間を参照しないので、バーに出していません"
}
}
],
"controls": [
{
"var": "period",
"type": "date",
"label": "対象期間",
"default": "thisMonth",
"presets": [
"thisMonth",
"lastMonth",
"thisYear",
"lastYear",
"custom"
],
"customFrom": "today-3m",
"customTo": "thisMonthEnd"
}
]
},
{
"id": "t3",
"name": "滞留・終売",
"layout": [
{
"i": "r1",
"x": 0,
"y": 0,
"w": 36,
"h": 9
},
{
"i": "r2",
"x": 36,
"y": 0,
"w": 24,
"h": 9
}
],
"panes": [
{
"id": "r1",
"type": "table",
"title": "動きの止まった製品(最終取引が古い順)",
"description": "最終取引が古く、在庫が残っている製品ほど上に出ます。**生産終了なのに在庫が残っている**ものが処分の候補です。",
"sql": "CREATE TEMP TABLE #z AS SELECT 製品名, SUM(個数_在庫計算用) AS 在庫数, MAX(日付) AS 最終取引 FROM APP4228 GROUP BY 製品名;\nSELECT z.最終取引, m.製品名, m.生産状況, z.在庫数, z.在庫数 * m.仕入価格 AS 滞留金額\nFROM APP4229 m INNER JOIN #z z ON m.製品名 = z.製品名\nORDER BY z.最終取引",
"options": {
"columns": {
"在庫数": {
"align": "right",
"format": {
"kind": "number",
"thousandSep": true
}
},
"滞留金額": {
"align": "right",
"format": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
}
}
},
"totals": {
"滞留金額": "sum"
},
"rules": [
{
"kind": "threshold",
"column": "生産状況",
"op": "=",
"value": "生産終了",
"tone": "warn",
"scope": "row"
},
{
"kind": "heatmap",
"column": "滞留金額",
"scheme": "red"
}
]
}
},
{
"id": "r2",
"type": "markdown",
"title": "滞留の見つけ方",
"sql": "",
"options": {
"markdown": "### 「動いていない」は在庫数では分からない\n\n在庫が多い製品が問題とは限りません。**よく売れて多めに持っている**のかもしれないからです。効くのは **最終取引がいつか** です。\n\nこの表は最終取引の古い順に並べています。上に来る製品が、棚に置きっぱなしになっている候補です。\n\n- 行が黄色い製品は **生産終了**。追加入荷が無いのに在庫が残っています\n- 金額の色が濃いほど、寝ている金額が大きいという意味です\n\n### 期間で絞ると未一致が出ます\n\n**入出庫の推移**タブで対象期間を「今月」にすると、生産終了の製品は 1 件も出てきません。マスタ起点の `LEFT JOIN` にすれば、その状態が「在庫はあるが今月の動きが無い」行として一覧に残ります。"
}
}
]
}
],
"viewName": "【在庫】ダッシュボード"
},
"2": {
"name": "在庫分析",
"layout": [],
"panes": [],
"tabs": [
{
"id": "t1",
"name": "在庫の評価",
"layout": [
{
"i": "p1",
"x": 0,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "p2",
"x": 20,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "p3",
"x": 40,
"y": 0,
"w": 20,
"h": 2
},
{
"i": "p4",
"x": 0,
"y": 2,
"w": 60,
"h": 6
},
{
"i": "p5",
"x": 0,
"y": 8,
"w": 60,
"h": 6
},
{
"i": "p6",
"x": 0,
"y": 14,
"w": 60,
"h": 5
}
],
"panes": [
{
"id": "p1",
"type": "kpi",
"title": "在庫金額",
"description": "現在庫 × 仕入価格の合計。現在庫は履歴の符号付き合計を SQL で計算しているので、**計算フィールドが無いアプリでも動きます**(レシピ D20)。",
"sql": "WITH s AS (\n SELECT 製品名, SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS 現在庫\n FROM APP4228 GROUP BY 製品名\n)\nSELECT SUM(s.現在庫 * m.仕入価格) AS メイン値\nFROM APP4229 m\nINNER JOIN s ON m.製品名 = s.製品名",
"options": {
"format": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
}
}
},
{
"id": "p2",
"type": "kpi",
"title": "滞留在庫金額(生産終了品)",
"description": "生産終了なのに残っている在庫の金額。1 円でもあれば赤にして、処分の判断を促します(S7 の KPI)。",
"sql": "WITH s AS (\n SELECT 製品名, SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS 現在庫\n FROM APP4228 GROUP BY 製品名\n)\nSELECT SUM(s.現在庫 * m.仕入価格) AS メイン値\nFROM APP4229 m\nINNER JOIN s ON m.製品名 = s.製品名\nWHERE m.生産状況 = '生産終了'",
"options": {
"format": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
},
"rules": [
{
"target": "value",
"op": ">=",
"value": 1,
"tone": "crit",
"apply": "text"
}
]
}
},
{
"id": "p3",
"type": "kpi",
"title": "総在庫数",
"description": "全履歴の入庫 − 出庫。**残高は時点の値**なので期間の概念がありません。",
"sql": "SELECT SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS メイン値 FROM APP4228",
"options": {
"format": {
"kind": "number",
"suffix": " 個",
"thousandSep": true
}
}
},
{
"id": "p4",
"type": "table",
"title": "製品別サマリ — 現在庫・在庫日数・判定",
"description": "在庫日数 = 現在庫 ×90 ÷ 直近 90 日の出庫。**2 週間未満は「要補充」、90 日出庫ゼロは「滞留」**。出庫ゼロの製品は在庫日数を空欄にしています(0 ではなく「定義できない」が正しいため。レシピ D20)。",
"sql": "DECLARE @d90 = DATE_ADD(CURRENT_DATE(), -89, 'DAY');\nWITH s AS (\n SELECT 製品名, SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS 現在庫\n FROM APP4228 GROUP BY 製品名\n), d AS (\n SELECT 製品名, SUM(個数) AS 出庫90\n FROM APP4228\n WHERE 入出庫区分 = '出庫' AND 日付 >= @d90\n GROUP BY 製品名\n)\nSELECT m.製品名, m.分類, m.生産状況, s.現在庫,\n s.現在庫 * m.仕入価格 AS 在庫金額,\n CASE WHEN d.出庫90 = '' THEN '' ELSE ROUND(s.現在庫 * 90 / d.出庫90, 1) END AS 在庫日数,\n CASE WHEN d.出庫90 = '' THEN '滞留'\n WHEN s.現在庫 * 90 / d.出庫90 < 14 THEN '要補充'\n ELSE '' END AS 判定\nFROM APP4229 m\nLEFT JOIN s ON m.製品名 = s.製品名\nLEFT JOIN d ON m.製品名 = d.製品名\nORDER BY 在庫金額 DESC",
"options": {
"density": "compact",
"columns": {
"現在庫": {
"align": "right",
"format": {
"kind": "number",
"thousandSep": true
}
},
"在庫金額": {
"align": "right",
"format": {
"kind": "number",
"prefix": "¥",
"thousandSep": true
}
},
"在庫日数": {
"align": "right",
"format": {
"kind": "number",
"suffix": " 日"
}
},
"判定": {
"align": "center"
}
},
"totals": {
"現在庫": "sum",
"在庫金額": "sum"
},
"rules": [
{
"kind": "threshold",
"column": "判定",
"op": "=",
"value": "要補充",
"tone": "crit",
"scope": "row"
},
{
"kind": "threshold",
"column": "判定",
"op": "=",
"value": "滞留",
"tone": "warn",
"scope": "row"
},
{
"kind": "heatmap",
"column": "在庫金額",
"scheme": "red"
}
]
}
},
{
"id": "p5",
"type": "combo",
"title": "月次の純増減(棒)と月末在庫(線)",
"description": "純増減の累積が月末在庫。累積は**集計ウィンドウ関数**(SUM OVER + ROWS)で計算しています(レシピ D21)。線が下がり続けたら、全社の在庫を取り崩している合図です。",
"sql": "WITH mo AS (\n SELECT DATE_FORMAT(日付, '%Y-%m') AS 年月,\n SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END) AS 純増減\n FROM APP4228 GROUP BY 年月\n)\nSELECT 年月, 純増減,\n SUM(純増減) OVER (ORDER BY 年月 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS 月末在庫\nFROM mo ORDER BY 年月",
"options": {
"legend": "bottom",
"bars": [
"純増減"
],
"lines": [
"月末在庫"
],
"valueFormat": {
"kind": "number",
"thousandSep": true,
"suffix": " 個"
},
"valueFormatRight": {
"kind": "number",
"thousandSep": true,
"suffix": " 個"
}
}
},
{
"id": "p6",
"type": "markdown",
"title": "この画面の読み方(残高型レシピ D20・D21・S7)",
"sql": "",
"options": {
"markdown": "### 在庫は「残高型」— 現在値 = 全履歴の合計\n\n売上のような *期間で切って足す* 集計と違い、**現在庫は入庫と出庫を全期間ぶん足した結果**です。だからこのタブに期間のつまみはありません。\n\n- **計算フィールドは使っていません。** 符号は SQL の `SUM(CASE WHEN 入出庫区分 = '出庫' THEN -個数 ELSE 個数 END)` が付けます。同じパックの **在庫ダッシュボード** サンプルは kintone 側の計算フィールドで同じことをしています — どちらでも結果は一致します(レシピ D20)\n- **在庫日数の空欄はバグではありません。** 90 日出庫ゼロの製品は割り算が NaN になるため、`CASE WHEN 出庫90 = '' THEN ''` で空欄に落としています。「出庫が無い = 在庫日数を定義できない」の正直な表現です\n- **取得は共有されます。** KPI 3 枚・表の現在庫・月次推移は、どれも押し下げ後クエリが空 =「入出庫履歴の全件」という同じキーなので共有キャッシュに合流し、ペインごとに参照する列が違っても「最初の取得 + 列がそろう取り直し」の**多くて 2 回**に収まります。別キーになるのは表の「直近 90 日の出庫」だけ(設計は S7)\n- **月末在庫は履歴開始を 0 とした帳簿値。** 履歴より前から在庫があった場合は全期間が一律に過小になります。正しくするには期首在庫(棚卸)を履歴に 1 レコード入れます\n- 履歴が取得上限(既定 10,000 件)を超えると残高型は WHERE では逃げられません。期首残高レコードで履歴を締める運用に切り替えます(レシピ集「適用限界」)"
}
}
]
}
],
"viewName": "【在庫】分析"
}
}
}
}














