diff --git a/Spec.md b/Spec.md
index 19a62e9..0b30ee7 100644
--- a/Spec.md
+++ b/Spec.md
@@ -134,6 +134,13 @@
改訂133: 2026-09-23(**標準時間の見直し候補を生産性モニタリングに追加した**(`api/productivity/standard-time-review`。B-60-10-05)。標準時間の予実は作業指示ごとに並ぶだけで、工順マスタを見直すきっかけにつながっていなかった(実践的MES導入ガイド 8.3.2(4):標準時間などの業務パラメータは定期的に再設計する)。工順の工程ごとに実績の中央値をマスタの現在値と比べ、件数としきい値で候補を挙げる。マスタは書き換えない。新規エンティティは無い。3.2節・6.1節に反映)
改訂134: 2026-09-23(1.3節に**エネルギー管理を[将来拡張]とする**ことを明記した。仕様にエネルギーの記述が無く、対象かどうかが読めなかった(実践的MES導入ガイド 3.4.2・4.4.7・7.2.2 は、MES の役割を FEMS へ製造オーダ別の分母を渡すことと位置づける)。計測・総量監視・ユーティリティ制御は FEMS/FMCS の領域として持たず、実装する場合は設備×期間の使用量をCSVで受け取り稼働区間の時間比で作業指示へ按分する形とした。機能の追加・変更は無い)
改訂135: 2026-09-23(**検査項目・基準に必要スキル・資格を持たせ、検査実績の登録時に検査員と照合する**ようにした(`InspectionItem.RequiredSkillId`。F-20-30-01)。改訂130で保全を実装した際、検査の見直し(改訂125〜128)と衝突するため後回しにしていたもの。必要スキルは発行時に `InspectionOrderItem` へ写し(作業指示の必要スキルと同じ)、発行後にマスタを変えても発行済みの検査の照合条件は変わらない。写す値なので、CSV取込で変えると版数が上がる。判定は差立・着手・保全と同じ `SkillQualificationPolicy`。実績の訂正では照合しない。マスタCSVの検査項目に `RequiredSkillCode` 列を追加(列が無ければ現在値のまま)。3.6節・5.1節・5.4節に反映)
+改訂143: 2026-09-24(3.8節:実績CSVに保全計画 `maintenance-plans` を追加した(CSV対応バックログで保留にしていたもの)。計画の登録を `MaintenancePlansController.Create` から `MaintenanceOrderService.CreatePlanAsync` へ移し、単票APIとCSV取込の両方から呼ぶ(API契約は変えない)。計画には番号が無いため、保全指示CSVに `FromPlan` 列を足し、設備と予定日が一致する未指示の計画が1件だけのときにその計画から指示を作る。サンプル `samples/actual-csv/18_maintenance-plans.csv` を追加し、以降のサンプルの番号を1つずつ繰り下げた(19〜30))
+改訂142: 2026-09-24(3.8節:実績CSVに不適合 `nonconformances`・サンプル品保管 `sample-storages` を追加した(CSV-07)。不適合の対応と保管サンプルが画面からしか進められなかった。検査の不合格で自動起票された不適合は、ロット番号で指す(そのロットの未完了の不適合が1件だけのときに限る)。サンプルの採取・保管の終了を `SampleStoragesController` から新設の `SampleStorageService` へ移し、単票APIとCSV取込の両方から呼ぶ(API契約は変えない。サンプル番号の手入力はCSVのみ)。サンプル `samples/actual-csv/28〜29` を追加)
+改訂141: 2026-09-24(3.8節:実績CSVに搬送指示 `transfer-orders`・ピッキング指示 `picking-orders`・棚卸指示 `stocktakes`・実棚数 `stocktake-counts` を追加した(CSV-06)。物流の指示系と倉庫業務進捗がサンプルで空のままだった。搬送指示の作成・実行・取消を `TransferOrdersController` から新設の `TransferOrderService` へ移し、単票APIとCSV取込の両方から呼ぶ(API契約は変えない)。ピッキング・棚卸は後続の行・ファイルから指すため番号を手入力にする(CSV-01 の手入力番号)。サンプル `samples/actual-csv/24〜27` を追加)
+改訂140: 2026-09-24(3.8節:実績CSVに在庫オペレーション `inventory-operations` を追加した(CSV-05)。在庫の移動・調整・分割・統合などが画面からしか行えなかった。9つの操作を1種別にまとめ、`Operation` 列で選ぶ。分割・振替(`LotOperationService` の派生ロット作成)は保存が2回に分かれるため自前でトランザクションを張っていたが、CSV取込の外側のトランザクションがあるときはそれに乗るようにした(入れ子のトランザクションは張れない。単票からの呼び出しは従来どおり)。サンプル `samples/actual-csv/23_inventory-operations.csv` を追加)
+改訂139: 2026-09-24(3.8節:実績CSVに治工具の利用実績 `tool-usages`・治工具の引当 `tool-issues`・検査機の校正 `calibrations` を追加した(CSV-04)。治工具の寿命・引当と校正がサンプルで試せなかった。利用実績の登録を `ToolUsagesController` から `ShopFloorReportService.AddToolUsageAsync` へ、校正の記録を `InspectionDevicesController` から新設の `InspectionDeviceService` へ移し、単票APIとCSV取込の両方から呼ぶ(API契約は変えない)。利用実績は記録日時をCSVでだけ指定できる。引当には番号が無いので、1行の `Issue`・`Return` 列で払出・返却まで進める。サンプル `samples/actual-csv/20〜22` を追加し、19 の治工具メンテは寿命をリセットしないようにした(リセットすると 20 の過去日時の利用実績が累計に入らないため))
+改訂138: 2026-09-24(3.8節:実績CSVに保全指示 `maintenance-orders`・保全実績 `maintenance-records` を追加した(CSV-03)。保全指示・実績は画面からしか入らず、サンプルだけでは消耗材モニタリング・保全の履歴を試せなかった。保全指示は後続の実績CSVから指すため番号を必須の手入力にし、`MaintenanceOrderService.CreateAsync` の手入力番号(CSV-01)を使う。計画保全を作れるロールを `MaintenanceOrderService.PlannedOrderRoles` に置き、単票APIとCSV取込で共有する。実績は `AddRecordAsync` を通すので、在庫引落し・資産管理部品の拒否・必要スキルの照合・設備の復帰は単票と同じ。サンプル `samples/actual-csv/18〜19` を追加し、保全部品の受入(ロット番号指定)を `01_receiving.csv` に足した)
+改訂137: 2026-09-24(3.8節:保全手順書をマスタCSVの種別 `maintenance-procedures` として出力・取込できるようにした(CSV-02)。マスタで唯一CSVに対応しておらず、サンプルだけでは保全手順書を用意できなかった。列は手順書番号(キー)・表題・対象設備の資産番号・対象治工具コード・必要スキル・資格コード・手順ステップ・有効。更新は単票APIと同じく版数を上げる。単票APIは参照中の手順書の無効化を制限していないため、CSVでも拒否しない(保全指示は手順書を版数ごと固定せず、無効な手順書は新しい指示で選べなくなるだけ)。取込の権限は単票と同じ `MaintenanceManage`。全マスタの一括ZIPの最後(20番)に入る。サンプル `samples/master-csv/20_maintenance-procedures.csv` を追加)
改訂136: 2026-09-23(画面の目視確認で見つかった2点を直した。①製造リードタイムの異常値判定で**四分位範囲を1日を下限**にした(多くの指図が同じ日数だと範囲が0になり、1日長いだけの指図まで異常値に出ていた。3.2節)。②スキル照合の文面を「作業者」から「**担当者**」に改めた(差立・着手に加え、保全の実施者・検査員にも同じ文面が出るため。利用者ガイドの例とトラブルシューティングも合わせた))
参考: みんなのMES(min-MES) https://min-mes.com/ / OSS: https://github.com/mihatama/open-mes-project
@@ -351,9 +358,9 @@ AGV/AMR連携(D-50-20-02)・リサイクル管理(D-50-30-02)・VMI/外
- 生産実績も稼働ログも**開始時刻が属する製造日**で振り分ける(品質分析・生産性・OEEも同じ)。稼働は工程・品目では絞れない(作業指示との紐付けが任意のため)。生産実績は設備では分けられない(設備が作業指示に固定されないため)。求められない指標は0でなくnull(「実績なし」と区別する)。ライン軸は最下段の作業区を祖先のライン段へまとめる
- 帳票・ラベル発行基盤(作業指示書・現品票・検査成績書・出荷伝票・出荷判定書・棚卸表等の印刷/PDF出力。B-10-30-04、C-20-10-05、D-10-10-03、D-50-30-05〜06、H-10-10-04)
- 監査ログ(実績訂正・検査訂正・マスタ変更等の変更履歴。7.6節)
-- **マスタのCSV一括入出力**:各マスタ(品目/MBOM/工順/工程/作業区/設備/設備の保全部品/治工具/ロケーション/検査項目・基準/**検査機・測定器**/工程管理項目/チェックリスト/作業手順書/勤務シフト/スキル・資格/ユーザー/ユーザーのスキル資格)のCSV出力・テンプレート出力・一括取込。取込はコード(自然キー)によるupsertで、他マスタはコードで参照する。文字コードはUTF-8/Shift_JISを自動判別し、出力はExcel互換(UTF-8 BOM+CRLF)。行単位で検証し、**1行でもエラーがあれば全件ロールバック**して行番号付きのエラー一覧を返す。反映せず検証のみ実行するモードを持つ。**参照されている状態のマスタを無効化できないという制約は、単票APIと同じ判定をCSV取込にも通す**(工順から参照中の作業手順書、在籍中の従業員が所属する直など)。片方にしか書かないとCSVから迂回できてしまうため、判定はポリシーへ集約する。なお**マスタの無効化は各マスタ画面から、再有効化はCSV取込の `IsActive` 列から**行う(全マスタ共通。画面に再有効化の導線は持たせていない)
+- **マスタのCSV一括入出力**:各マスタ(品目/MBOM/工順/工程/作業区/設備/設備の保全部品/治工具/ロケーション/検査項目・基準/**検査機・測定器**/工程管理項目/チェックリスト/作業手順書/勤務シフト/スキル・資格/ユーザー/ユーザーのスキル資格/**保全手順書**)のCSV出力・テンプレート出力・一括取込。保全手順書(`maintenance-procedures`)は単票APIと同じく保全の権限(`MaintenanceManage`)で取り込み、取込欄は保全管理画面の保全手順書タブに置く(一括ZIPでは最後の番号。対象設備・治工具・必要スキルをコードで参照するため)。取込はコード(自然キー)によるupsertで、他マスタはコードで参照する。文字コードはUTF-8/Shift_JISを自動判別し、出力はExcel互換(UTF-8 BOM+CRLF)。行単位で検証し、**1行でもエラーがあれば全件ロールバック**して行番号付きのエラー一覧を返す。反映せず検証のみ実行するモードを持つ。**参照されている状態のマスタを無効化できないという制約は、単票APIと同じ判定をCSV取込にも通す**(工順から参照中の作業手順書、在籍中の従業員が所属する直など)。片方にしか書かないとCSVから迂回できてしまうため、判定はポリシーへ集約する。なお**マスタの無効化は各マスタ画面から、再有効化はCSV取込の `IsActive` 列から**行う(全マスタ共通。画面に再有効化の導線は持たせていない)
- **生産計画(`production-plans`)は取込の仕組みだけをこれに乗せる**。生産計画はマスタではなく日付を持つ業務データだが、改訂のたびに同じ計画を上書きしたいので、常に新規登録する実績CSVではなくキーで上書きするマスタCSVの取込を使う(A-30-10-01)。キーは `BusinessDate, ProductCode, ProcessCode, WorkCenterCode(任意)` で、同じキーなら計画数量(と `Note` 列があれば備考)を上書きし、無ければ追加する。**`WorkCenterCode` が空の行も1つのキーとして扱い**、同じ製造日・品目・工程の作業区なしの計画を上書きする(二重に追加しない)。計画数量は0以上。ファイル内で同じキーが複数行にあればエラー。取り込んだ後に同じ製造日・品目・工程で作業区の範囲が重なる行(既存の計画との間・ファイル内とも)もエラー。数量とキーの判定は単票API(`api/production-plans`)と共通の `ProductionPlanPolicy` に置く。取込の権限は単票の登録と同じ `ProductionManage`(種別の `WriteRoles`)。取り込み直しても変わらない行は更新日時を動かさない。**マスタではないので一括ZIPの取込・出力には含めない**(日々増えるデータを、マスタの移行や全件出力に巻き込まないため。ZIPに入っていれば取込を拒む)。取込は生産計画画面の計画登録タブから1ファイルずつ行い、出力は一覧と同じ絞り込み(製造日の期間・品目・工程・作業区)の `api/production-plans/csv` で行う
-- **実績のCSV一括取込**:試験・移行・教育用に、実績(取引データ)をCSVで一括登録する(`api/actuals/csv/{kind}`。テンプレート出力と取込。対応種別は受入 `receiving`・製造指図 `manufacturing-orders`・段取り実績 `setup-records`・チェックリスト実施 `checklist-records`・部材投入 `consumptions`・生産実績 `production-records`・製造条件データ `data-records`・検査 `inspections`・作業時間 `work-time-records`・製造トラブル報告 `trouble-reports`・設備稼働記録 `equipment-logs`・出荷指示 `shipping-orders`・出荷判定 `shipment-judgments`・出荷実行 `shipments`)。マスタCSVと同じく**1行でもエラーがあれば全件ロールバック**し、行番号付きのエラー一覧を返し、検証のみのモードを持つ。マスタCSVとの違いは次のとおり。①**取込は常に新規登録**で既存の実績を更新しない(実績の訂正は理由付きの訂正機能で行う)。②行ごとに**単票APIと同じ業務サービスを通す**(受入は `ReceivingService`、製造指図は `ManufacturingOrderService`、作業指示の実行記録は `WorkOrderExecutionService`、検査は `InspectionService`、作業時間・トラブル報告は `ShopFloorReportService`、出荷指示・出荷実行は `ShippingService`、出荷判定は `ShipmentJudgmentService`)——実績は在庫・ロット・状態履歴を連鎖して動かすため、行をエンティティへ直接写すと判定や監査ログが迂回される。③取込の権限は**種別ごとに単票APIと同じロールグループ**(受入は在庫・物流オペレーション)。④行は保存しながら進めるので、後の行は前の行の結果(登録済みロット番号の重複判定など)を前提にでき、検証のみも実際に登録してからロールバックする。他のマスタ・実績はIDではなくコード・番号で参照する。⑤**作業指示は「指図番号+工程順序」で指す**。自動採番の指図番号は取込前に分からないため、製造指図CSVに限り指図番号の手入力を許す(自動採番の形式 `MO〜` と重なる番号は、採番テーブルの連番と後で衝突するため拒否)。製造指図CSVは1行で登録・承認・工程展開まで進められるが、承認は省略できない(展開は承認済みの指図だけ)。⑥**実行記録の記録者は取込を実行したユーザー**とし、作業者を指定する列は持たない(他人名義の実績を作れると、記録者に意味が無くなる)。投入ロットはロット番号で、日時はオフセットの無い値を工場の時刻として読む。生産実績の直は開始日時で決まる。⑦**検査CSVは `InspectionKey` が同じ行を1件の検査指示にまとめる**。キーはファイルの中だけの名前でDBに残さない(検査指示番号は自動採番のままにし、後続のCSVから検査指示を指す必要も無いため)。まとまりに含まれる検査項目がその指示の対象になり、`Judge=true` なら実績に続けて総合判定する(ロットの状態反映・不合格時の不適合起票も単票APIと同じ)。⑧**出荷は指示・判定・実行の3種別に分ける**(判定は品質保証、指示と実行は物流の権限で、1種別にまとめると③を満たせない)。出荷指示CSVは出荷番号が同じ行を1件の指示(1行=1品目の明細。同じ品目を2行書けない)にまとめ、後続の判定・実行CSVから番号で指せるよう**出荷番号を必須の手入力**とする(⑤と同じ理由で自動採番の形式 `SH〜` と重なる番号は拒否)。出荷判定CSVは1行=1件の判定で、`Approve=true` なら続けて承認する。出荷実行CSVは出荷番号が同じ行を1回の出荷にまとめ、**承認済みの判定(可・特採)とロットの使用可否は単票APIと同じく出荷実行の処理で判定する**(判定の無い指示を含むファイルは全件取り消し)
+- **実績のCSV一括取込**:試験・移行・教育用に、実績(取引データ)をCSVで一括登録する(`api/actuals/csv/{kind}`。テンプレート出力と取込。対応種別は受入 `receiving`・製造指図 `manufacturing-orders`・段取り実績 `setup-records`・チェックリスト実施 `checklist-records`・部材投入 `consumptions`・生産実績 `production-records`・製造条件データ `data-records`・検査 `inspections`・作業時間 `work-time-records`・製造トラブル報告 `trouble-reports`・設備稼働記録 `equipment-logs`・出荷指示 `shipping-orders`・出荷判定 `shipment-judgments`・出荷実行 `shipments`・保全計画 `maintenance-plans`・保全指示 `maintenance-orders`・保全実績 `maintenance-records`・治工具の利用実績 `tool-usages`・治工具の引当 `tool-issues`・検査機の校正 `calibrations`・在庫オペレーション `inventory-operations`・搬送指示 `transfer-orders`・ピッキング指示 `picking-orders`・棚卸指示 `stocktakes`・実棚数 `stocktake-counts`・不適合 `nonconformances`・サンプル品保管 `sample-storages`)。マスタCSVと同じく**1行でもエラーがあれば全件ロールバック**し、行番号付きのエラー一覧を返し、検証のみのモードを持つ。マスタCSVとの違いは次のとおり。①**取込は常に新規登録**で既存の実績を更新しない(実績の訂正は理由付きの訂正機能で行う)。②行ごとに**単票APIと同じ業務サービスを通す**(受入は `ReceivingService`、製造指図は `ManufacturingOrderService`、作業指示の実行記録は `WorkOrderExecutionService`、検査は `InspectionService`、作業時間・トラブル報告は `ShopFloorReportService`、出荷指示・出荷実行は `ShippingService`、出荷判定は `ShipmentJudgmentService`、保全指示・保全実績は `MaintenanceOrderService`、治工具の利用実績は `ShopFloorReportService`、治工具の引当は `ToolIssueService`、校正は `InspectionDeviceService`、在庫オペレーションは `LotOperationService`、搬送は `TransferOrderService`、ピッキングは `PickingService`、棚卸は `StocktakeService`、不適合は `NonconformanceService`、サンプル品保管は `SampleStorageService`)——実績は在庫・ロット・状態履歴を連鎖して動かすため、行をエンティティへ直接写すと判定や監査ログが迂回される。③取込の権限は**種別ごとに単票APIと同じロールグループ**(受入は在庫・物流オペレーション)。④行は保存しながら進めるので、後の行は前の行の結果(登録済みロット番号の重複判定など)を前提にでき、検証のみも実際に登録してからロールバックする。他のマスタ・実績はIDではなくコード・番号で参照する。⑤**作業指示は「指図番号+工程順序」で指す**。自動採番の指図番号は取込前に分からないため、製造指図CSVに限り指図番号の手入力を許す(自動採番の形式 `MO〜` と重なる番号は、採番テーブルの連番と後で衝突するため拒否)。製造指図CSVは1行で登録・承認・工程展開まで進められるが、承認は省略できない(展開は承認済みの指図だけ)。⑥**実行記録の記録者は取込を実行したユーザー**とし、作業者を指定する列は持たない(他人名義の実績を作れると、記録者に意味が無くなる)。投入ロットはロット番号で、日時はオフセットの無い値を工場の時刻として読む。生産実績の直は開始日時で決まる。⑦**検査CSVは `InspectionKey` が同じ行を1件の検査指示にまとめる**。キーはファイルの中だけの名前でDBに残さない(検査指示番号は自動採番のままにし、後続のCSVから検査指示を指す必要も無いため)。まとまりに含まれる検査項目がその指示の対象になり、`Judge=true` なら実績に続けて総合判定する(ロットの状態反映・不合格時の不適合起票も単票APIと同じ)。⑧**出荷は指示・判定・実行の3種別に分ける**(判定は品質保証、指示と実行は物流の権限で、1種別にまとめると③を満たせない)。出荷指示CSVは出荷番号が同じ行を1件の指示(1行=1品目の明細。同じ品目を2行書けない)にまとめ、後続の判定・実行CSVから番号で指せるよう**出荷番号を必須の手入力**とする(⑤と同じ理由で自動採番の形式 `SH〜` と重なる番号は拒否)。出荷判定CSVは1行=1件の判定で、`Approve=true` なら続けて承認する。出荷実行CSVは出荷番号が同じ行を1回の出荷にまとめ、**承認済みの判定(可・特採)とロットの使用可否は単票APIと同じく出荷実行の処理で判定する**(判定の無い指示を含むファイルは全件取り消し)。⑨**保全は指示と実績の2種別に分ける**。保全指示CSVは1行=1件の指示で、保全実績CSVから指せるよう**保全指示番号を必須の手入力**とする(⑤と同じ理由で `MT〜` は拒否)。単票APIと同じく突発依頼は誰でも、計画保全の指示は保全担当者(`MaintenanceOrderService.PlannedOrderRoles`)だけが登録でき、CSVでは行ごとに取り込んだユーザーのロールで判定する。**保全計画CSV**は1行=1件の計画で常に新規登録する(設備×年度×区分でも同じ区分の計画が複数ありうるため自然なキーが無く、マスタCSVのように上書きしない。権限は単票と同じ `MaintenanceManage`)。計画には番号が無いので、保全指示CSVの `FromPlan=true` の行は**設備と予定日が一致する未指示の計画が1件だけのとき**にその計画から作り、計画を指示済みにする(0件・2件以上は行エラー。同じ設備の計画は予定日を分ける)。保全実績CSVは保全指示番号が同じ行を1件の実績にまとめ、1行に消費部材を1つ(ロット番号・払出元・数量)書ける。実施者は取り込んだユーザーで、手順書の必要スキルもこのユーザーと照合する。登録で指示は完了するので、1件の指示に実績は1件だけ。資産管理部品を書いた行・在庫不足は単票と同じく拒否する。⑩**番号の無い記録は1行で完結させる**。治工具の引当CSVは1行=1件の引当で、`Issue`・`Return` の列で払出・返却まで進め、後のファイルからは指さない(引当・払出の両方で `ToolIssuePolicy` が使用中・メンテ中・寿命到達を拒否する)。治工具の利用実績CSVは記録日時(`RecordedAt`)を指定でき、空欄なら取り込んだ時刻とする(寿命の累計はメンテで寿命をリセットした時刻より後の記録だけを数えるので、リセットより前の日時の記録は累計に入らない)。検査機の校正CSVは1行=1回の校正で、単票と同じく検査機の最終校正日・次回期限(省略時は校正周期から)を更新する。⑪**在庫オペレーションは1種別にまとめ、`Operation` 列で操作を選ぶ**(移動・数量調整・状態変更・分割・統合・振替・廃棄・返品・払出戻し。操作ごとに種別を分けると取込画面のパネルが9枚増えるため)。1行=1操作で、単票と同じ `LotOperationService` の同名メソッドを呼ぶ。操作ごとの必須列はコードで検査する(数量調整の理由は単票でも必須)。分割・振替は新ロット番号(`NewLotNumber`)を指定できるので、同じファイルの後の行から新しいロットを指せる。ロット系譜・状態履歴・監査ログは単票と同じに残る。⑫**物流の指示**:搬送指示CSVは番号が無いので1行=1件で、`Execute` の列で移動の実行まで進める。ピッキング指示CSVはピッキング番号(必須の手入力。`PK〜` は拒否)が同じ行を1件の指示にまとめ、1行=1明細(品目と数量)。ロット・ロケーションは単票と同じく先入れ先出し(有効期限優先。期限切れは引き当てない)で自動引当し、`Execute` で払出まで進める。棚卸指示CSVは棚卸番号(必須の手入力。`ST〜` は拒否)と対象ロケーションで作り、作成時点の在庫が明細になる。実棚数CSVは棚卸番号と明細のロット番号+ロケーションで指し、`Finalize` で確定(差異の棚卸調整)まで進める。⑬**不適合CSVは1行=1件の不適合**で、不適合番号(未登録なら新規起票。`NC〜` は拒否)か、番号を空欄にしてロット番号で指し、`Action`(対応指示)→`ActionRecord`(対応記録)→`Approve`(承認)の列があればその順に進める。検査の不合格で自動起票された不適合は番号が取り込むまで分からないため、**ロット番号で指せるのはそのロットの未完了(クローズ前)の不適合が1件だけのとき**に限る(0件・2件以上は行エラー。どれを進めるかを推測で決めない)。起票と対応記録は誰でも、対応指示と承認は単票と同じく品質管理の担当者だけ(行ごとに取り込んだユーザーのロールで判定)。⑭**サンプル品保管CSV**は1行=1件で、未登録のサンプル番号(`SP〜` は拒否)なら採取(在庫から抜く)、`Close`(払出・廃棄)を書けば保管を終える。登録済みの番号の行は保管の終了だけを行う
- **CSVのZIP一括取込・一括出力**:複数のCSVをまとめたZIPを1回で取り込む(マスタは `POST api/masters/csv/bundle`、実績は `POST api/actuals/csv/bundle`)。**ファイル名の昇順に取り込み、種別はファイル名の最初の「_」より後ろで決める**(`01_work-centers.csv` → `work-centers`。同じ種別を番号違いで複数入れてよい)。CSV以外のファイル(README.md等)は無視する。**全ファイルを1つのトランザクションで処理し、どれか1ファイルでもエラーがあれば全ファイルを取り消す**(後のファイルが前のファイルの登録結果を参照するのでファイルごとに保存はするが、途中まで反映された状態が残るとどこから取り込み直せばよいか分からなくなるため)。エラーが出たファイルより後は処理せず、未処理として名前を返す。**マスタと実績が混ざったZIPは受け付けない**(取込の権限も対象画面も異なるため、別のZIPにする)。取込には**ZIPに含まれる全種別の取込権限**が要り、1種別でも権限が無ければ何も取り込まない。種別が分からないファイルは取り込む前に拒否する。ZIP爆弾対策として、CSVの数・1ファイルと合計の展開後サイズに上限を設け、読みながら数えて超えたら打ち切る。一括出力(`GET api/masters/csv/bundle`)は全マスタを取り込む順の番号付きファイル名でZIPにし、そのまま一括取込に使える(参照を絞っている種別は権限が無ければ含めない。ユーザーはパスワードを出力しないため、新しいDBへ移すときはユーザー系のファイルに初期パスワードを書き足す)。実績の一括出力は持たない
- バーコード/QRスキャン共通コンポーネント(ブラウザのカメラAPI or USB HIDリーダー入力)
diff --git a/docs-dev/CodeMap.md b/docs-dev/CodeMap.md
index d2d376c..4c81fa1 100644
--- a/docs-dev/CodeMap.md
+++ b/docs-dev/CodeMap.md
@@ -24,7 +24,7 @@
| 作業手順書(SOP。版数管理) | I-30-40 / I-30-20-12 / B-10-30-03 | `WorkProceduresController.cs` `api/work-procedures`(更新で版数+1。**工順から参照中は無効化できない**。CSV取込も同じ判定) | Masters.cs: WorkProcedure(対象品目・工程は持たない。紐付けは `Routing.WorkProcedureId` 側)
本文を置けない手順書は `Reference`(文書番号・URL)だけでよい | `/masters` `Masters/WorkProceduresTab.razor`
工順への紐付けは `Masters/ProductsTab.razor`
作業指示での閲覧は `ProductionRecordEntry.razor` | `Tests/MasterTests.cs` `Tests/MasterCsvTests.cs` |
| 作業指示の手順書表示 | B-10-30-03 | `WorkOrdersController.cs` `GET api/work-orders/{id}/procedure`(紐付けなしは404) | Production.cs: WorkOrder(`WorkProcedureId` + `WorkProcedureVersion`)
**本文は固定せず版数だけスナップショット**。表示はマスタ現在値で、版数が食い違えば `IsRevised`(他のスナップショットとは方針が逆。Spec.md 5.7) | `/work-orders/{id}` `ProductionRecordEntry.razor` | `Tests/ProductionTests.cs` |
| マスタCSV一括入出力 | Spec.md 3.1 | `MasterCsvController.cs` `api/masters/csv`
ZIP一括取込・一括出力は `api/masters/csv/bundle`/`api/actuals/csv/bundle`(`Api/Services/CsvBundle.cs`。全ファイル1トランザクション・ファイル名昇順・種別はファイル名の「_」以降。出力の番号は `MasterCsvKinds.ImportOrder`。画面は `Web/Shared/CsvBundlePanel.razor`)
`Api/Services/MasterCsvService.cs` / `.Import.cs` / `MasterCsvKinds.cs` / `CsvTable.cs` / `CsvFile.cs`
**無効化したマスタを戻せるのはCSVの `IsActive` 列だけ**(画面に再有効化の導線は無い。全マスタ共通) | (各マスタ) | `Web/Shared/CsvIoPanel.razor`(各Tabに配置) | `Tests/MasterCsvTests.cs` |
-| 実績CSV一括取込(取込のみ。常に新規登録) | Spec.md 3.8 / D-10-10-02 / A-20-10-01 / B-10-10-01 / B-20-50 / B-30-10 / B-30-20 / B-40-10 / B-30-30-04 / C-20 / B-30-30-02 / B-60-10 | `ActualCsvController.cs` `api/actuals/csv`
`Api/Services/ActualCsvService.cs` / `ActualCsvKinds.cs`(種別ごとの取込ロール=単票APIと同じ定数)
列・行数の検証と結果は `CsvImport.cs` をマスタCSVと共有
**行ごとに単票APIと同じ業務サービスを呼ぶ**(受入は `ReceivingService`、製造指図の登録・承認・展開は `ManufacturingOrderService`、実行記録は `WorkOrderExecutionService`、検査は `InspectionService`、作業時間・トラブル・設備稼働記録は `ShopFloorReportService`。トランザクションは呼び出し側。失敗行では `ChangeTracker.Clear()`)
作業指示は「指図番号+工程順序」で指す(指図番号の手入力はCSVのみ。`MO〜` は拒否) | (各実績) | `/actual-csv` `Web/Pages/ActualCsvImport.razor`(`CsvIoPanel` を `BaseUrl="api/actuals/csv"` `CanExport="false"` で並べる。取込欄のロールは kinds の `WriteRoles`) | `Tests/InventoryTests.cs`(受入)
`Tests/ProductionTests.cs`(製造指図)
`Tests/ExecutionTests.cs`(実行記録)
`Tests/QualityTests.cs`(検査・作業時間・トラブル)
`Tests/MasterCsvTests.cs`(`samples/actual-csv` の通し取込、`samples/actual-csv-bulk`(3か月分。`scripts/New-SampleBulkActuals.ps1` で生成)の取込と設備稼働記録CSV) |
+| 実績CSV一括取込(取込のみ。常に新規登録) | Spec.md 3.8 / D-10-10-02 / A-20-10-01 / B-10-10-01 / B-20-50 / B-30-10 / B-30-20 / B-40-10 / B-30-30-04 / C-20 / B-30-30-02 / B-60-10 | `ActualCsvController.cs` `api/actuals/csv`
`Api/Services/ActualCsvService.cs`(入口と共通の読み取り)+領域ごとの partial `.Inventory.cs` / `.Production.cs` / `.Execution.cs` / `.Quality.cs` / `.Shipping.cs` / `.Maintenance.cs` / `.Tools.cs` / `.Logistics.cs`(**種別を足すときは領域の partial に置く**)/ `ActualCsvKinds.cs`(種別ごとの取込ロール=単票APIと同じ定数)
列・行数の検証と結果は `CsvImport.cs` をマスタCSVと共有
**行ごとに単票APIと同じ業務サービスを呼ぶ**(受入は `ReceivingService`、製造指図の登録・承認・展開は `ManufacturingOrderService`、実行記録は `WorkOrderExecutionService`、検査は `InspectionService`、作業時間・トラブル・設備稼働記録は `ShopFloorReportService`、保全指示・実績は `MaintenanceOrderService`、治工具の引当は `ToolIssueService`、校正は `InspectionDeviceService`、在庫オペレーションは `LotOperationService`、搬送は `TransferOrderService`、ピッキングは `PickingService`、棚卸は `StocktakeService`、不適合は `NonconformanceService`、サンプル品保管は `SampleStorageService`。トランザクションは呼び出し側。失敗行では `ChangeTracker.Clear()`)
作業指示は「指図番号+工程順序」で指す(指図番号の手入力はCSVのみ。`MO〜` は拒否) | (各実績) | `/actual-csv` `Web/Pages/ActualCsvImport.razor`(`CsvIoPanel` を `BaseUrl="api/actuals/csv"` `CanExport="false"` で並べる。取込欄のロールは kinds の `WriteRoles`) | `Tests/InventoryTests.cs`(受入)
`Tests/ProductionTests.cs`(製造指図)
`Tests/ExecutionTests.cs`(実行記録)
`Tests/QualityTests.cs`(検査・作業時間・トラブル)
`Tests/MasterCsvTests.cs`(`samples/actual-csv` の通し取込、`samples/actual-csv-bulk`(3か月分。`scripts/New-SampleBulkActuals.ps1` で生成)の取込と設備稼働記録CSV) |
## B. 製造実行
@@ -35,7 +35,7 @@
| 製造履歴訂正(訂正履歴+監査ログ) | B-70-30-01 | `ProductionRecordsController.cs` `api/production-records`(数量だけを訂正する。**直は記録時に固定した値なので動かない**) | Execution.cs: ProductionRecord / **ProductionRecordCorrection**(訂正前の値。Spec.md 5.7) | `ProductionRecordEntry.razor` の実績一覧から訂正
`/traceability` の履歴タブに訂正履歴を表示 | `Tests/ExecutionTests.cs` / `QualityTests.cs` |
| 作業時間記録(直接/間接) | B-30-30-02 / F-30-20-02 | `WorkTimeRecordsController.cs` `api/work-time-records` | Execution.cs: WorkTimeRecord | `/work-time` `WorkTime.razor` | `Tests/ExecutionTests.cs` |
| 製造トラブル報告 | B-40-10-06 / B-60-10 | `TroubleReportsController.cs` `api/trouble-reports` | Execution.cs: TroubleReport | `ProcessProgress.razor` 内 | `Tests/ExecutionTests.cs` |
-| 工程間搬送・移動指示 | B-50-10 / D-30-10-04 | `TransferOrdersController.cs` `api/transfer-orders`(更新系は在庫権限) | Execution.cs: TransferOrder | `/transfer-orders` `TransferOrders.razor`(`Inventory.razor` の「振替」は別機能の `api/inventory/transfer`) | `Tests/InventoryTests.cs` |
+| 工程間搬送・移動指示 | B-50-10 / D-30-10-04 | `TransferOrdersController.cs` `api/transfer-orders`(更新系は在庫権限)
作成・実行・取消は `Api/Services/TransferOrderService.cs`(実績CSVの種別 `transfer-orders` と共通。1行で実行まで) | Execution.cs: TransferOrder | `/transfer-orders` `TransferOrders.razor`(`Inventory.razor` の「振替」は別機能の `api/inventory/transfer`) | `Tests/InventoryTests.cs` |
| 設備稼働報告・稼働監視 | B-40-20 / E-20-10 | `EquipmentLogsController.cs` `api/equipment-logs`(`?workOrderId=` で絞り込み。登録の判定は `ShopFloorReportService.AddEquipmentLogAsync` で実績CSVと共通)
稼働サマリは `GET summary?from=&to=`(**製造日基準**。稼働の開始時刻が属する製造日で振り分ける。省略時は全期間) | `Core/Entities/Maintenance.cs`: EquipmentLog(`WorkOrderId` 任意=**PQC×EQCの交差点**。ロット履歴 H-30-10-04 はこれを辿る。`EquipmentLogStatus` への追加は**末尾のみ**:JSONが数値) | `/maintenance` `Maintenance/EquipmentLogsTab.razor` | `Tests/MaintenanceTests.cs` |
| 生産性モニタリング(歩留まり・直行率・標準時間予実) | B-60-10-05 | `ProductivityController.cs` `api/productivity`(製造日で期間指定。**リワーク指図の産出は分母に入れない**=救済分は歩留まりの分子だけ)
標準時間の見直し候補 `api/productivity/standard-time-review`(**比べる相手は工順マスタの現在値**。実績は中央値。時間の記録が無い作業指示は件数から外す)
製造リードタイム `api/productivity/lead-time`(**着手・完了は作業の記録の時刻**。状態履歴は実績CSV取込で取込時刻になるため使わない。異常値は四分位から) | Execution.cs: ProductionRecord / WorkTimeRecord / SetupRecord
Production.cs: WorkOrder(標準時間は**展開時の工順スナップショット**。マスタ現在値で引き直さない) | `/productivity` `Web/Pages/Productivity.razor`
`Web/Shared/ProductivityTable.razor` | `Tests/ProductionTests.cs` |
@@ -45,10 +45,10 @@
| 業務 | MES No | API | エンティティ | 画面 | テスト |
|:--|:--|:--|:--|:--|:--|
| 工程管理項目マスタ(指示値・許容範囲。版数付き) | B-30-30-04 | `ControlItemsController.cs` `api/control-items`
展開時のスナップショット取得は `WorkOrdersController.ControlItems` `api/work-orders/{id}/control-items` | Masters.cs: ControlItem(対象品目・工程は持たない)/ **RoutingControlItem**(工順の工程への紐付け。展開はこの紐付けどおり。紐付け中は無効化できない:`MasterDeactivationPolicy`)
Production.cs: **WorkOrderControlItem**(展開時点の指示値。判定・表示はこちらを使い、マスタ現在値を参照しない) | `/masters` `Masters/ControlItemsTab.razor`
工順への紐付けは `Masters/Products/ProductRoutingEditor.razor` | `Tests/MasterTests.cs` / `MasterCsvTests.cs` / `ProductionTests.cs` |
-| 検査機・測定器マスタ/校正管理 | C-20-50-03 | `InspectionDevicesController.cs` `api/inspection-devices`
校正の記録は `POST {id}/calibrations`(次回期限は校正周期から自動)/期限接近は `GET expiring?withinDays=` | Masters.cs: InspectionDevice(現在の校正状態)/InspectionDeviceCalibration(実施履歴) | `/masters` `Masters/InspectionDevicesTab.razor` | `Tests/MasterTests.cs` / `MasterCsvTests.cs` |
+| 検査機・測定器マスタ/校正管理 | C-20-50-03 | `InspectionDevicesController.cs` `api/inspection-devices`
校正の記録は `POST {id}/calibrations`(次回期限は校正周期から自動。処理は `Api/Services/InspectionDeviceService.cs`。実績CSVの種別 `calibrations` と共通)/期限接近は `GET expiring?withinDays=` | Masters.cs: InspectionDevice(現在の校正状態)/InspectionDeviceCalibration(実施履歴) | `/masters` `Masters/InspectionDevicesTab.razor` | `Tests/MasterTests.cs` / `MasterCsvTests.cs` |
| 検査項目・基準マスタ | C-10-10 | `InspectionItemsController.cs` `api/inspection-items`
対象品目/工程の意味(**「かつ」で組み合わせ、空は問わない**)と登録時の制約は `Api/Policies/InspectionItemPolicy.cs`(単票API・マスタCSV・検査指示の発行で共通) | Masters.cs: InspectionItem | `/masters` `Masters/InspectionItemsTab.razor` | `Tests/MasterTests.cs` |
| 検査指示・実績・判定・成績書 | C-20 | `InspectionOrdersController.cs` `api/inspection-orders`(発行・実績・判定の処理は `Api/Services/InspectionService.cs`。実績登録時に**検査機の校正期限を判定**:`InspectionDeviceCalibrationPolicy`。**検査員のスキル照合**も実績登録時(発行時に写した `InspectionOrderItem.RequiredSkillId` → `SkillQualificationPolicy`)。発行できるロットの状態・判定前の指示の重複は `InspectionLotPolicy`。合否(手の指定は自動判定と食い違えば拒否。訂正も同じ)とサンプリング数(判定の下限)は `InspectionJudgmentPolicy`。取消はロットを**発行前のステータス**へ戻す) | `Core/Entities/Quality.cs`
InspectionOrder / InspectionOrderItem(**発行時点の基準スナップショット**。判定・成績書はこちらを使い、マスタ現在値を参照しない:Spec.md 5.7) / InspectionResult / **InspectionResultCorrection**(訂正前の記録。詳細画面・成績書に表示) | `/inspections` `Inspections.razor`
`/inspections/{id}` `InspectionDetail.razor`
`/print/inspection/{id}` `Print/InspectionCertificate.razor` | `Tests/QualityTests.cs` |
-| 不適合・逸脱管理(特採・廃棄・保留) | C-30 / B-40-30 | `NonconformanceController.cs` `api/nonconformances`
記録・対応指示・承認と不適合の連鎖は `Api/Services/NonconformanceService.cs` | Quality.cs: NonconformanceReport | `/nonconformances` `Nonconformances.razor` | `Tests/QualityTests.cs` |
+| 不適合・逸脱管理(特採・廃棄・保留) | C-30 / B-40-30 | `NonconformanceController.cs` `api/nonconformances`
記録・対応指示・承認と不適合の連鎖は `Api/Services/NonconformanceService.cs`(実績CSVの種別 `nonconformances` も同じ。番号の分からない不適合はロット番号で指し、未完了が1件のときだけ) | Quality.cs: NonconformanceReport | `/nonconformances` `Nonconformances.razor` | `Tests/QualityTests.cs` |
| 品質分析(不良理由別・品目別・工程別・**直別**・期間別) | C-40-10 | `QualityAnalysisController.cs` `api/quality/summary`
管理図・工程能力(C-50-10-01〜02)は `ControlChartController.cs` `api/quality/control-chart` → 計算は `Api/Services/ControlChartCalculator.cs`(**群=検査指示**。係数表・群の揃え方・9点連続の判定はここだけ)。描画は `Web/Shared/Charts/ControlLineChart.razor` | Execution.cs: ProductionDefect(不良理由別の内訳)
ProductionRecord.`ShiftId`(**記録時に固定**。集計で引き直さない) | `/quality-analysis` `QualityAnalysis.razor`
`Web/Shared/BarMeter.razor` | `Tests/QualityTests.cs`・`ControlChartCalculatorTests.cs` |
| 不良理由マスタ | C-40-10-01 | `DefectReasonsController.cs` `api/defect-reasons` | Masters.cs: DefectReason | `/masters` `Masters/DefectReasonsTab.razor` | `Tests/MasterTests.cs` / `MasterCsvTests.cs` |
| チェックリストマスタ(HSE含む) | B-30-10 / G-20-20-02 | `ChecklistsController.cs` `api/checklists` | Masters.cs: Checklist / ChecklistItem | `/masters` `Masters/ChecklistsTab.razor` | `Tests/MasterTests.cs` |
@@ -58,13 +58,13 @@
| 業務 | MES No | API | エンティティ | 画面 | テスト |
|:--|:--|:--|:--|:--|:--|
| 受入・受入ロット採番 | D-10-10 | `ReceivingController.cs` `api/receiving` | `Core/Entities/Inventory.cs`: InventoryStock / InventoryTransaction
Production.cs: Lot | `/receiving` `Receiving.razor` | `Tests/InventoryTests.cs` |
-| 在庫オペレーション(照会・移動・調整・分割/統合・廃棄・期限) | D-10-30 / D-30-10 / D-40-40 | `InventoryController.cs` `api/inventory`
更新系(移動・調整・ステータス変更・分割・統合・振替・廃棄・返品・払出戻し)は `Api/Services/LotOperationService.cs`
在庫数量の増減そのものは `Api/Services/InventoryService.cs` | Inventory.cs: InventoryStock / InventoryTransaction | `/inventory` `Inventory.razor` | `Tests/InventoryTests.cs` |
-| 出庫・ピッキング・工程払出(FEFO自動引当) | D-20-10 / D-20-20 | `PickingOrdersController.cs` `api/picking-orders`
作成・実行・取消は `Api/Services/PickingService.cs` | Inventory.cs: PickingOrder / PickingLine | `/picking` `Picking.razor` | `Tests/InventoryTests.cs` |
+| 在庫オペレーション(照会・移動・調整・分割/統合・廃棄・期限) | D-10-30 / D-30-10 / D-40-40 | `InventoryController.cs` `api/inventory`
更新系(移動・調整・ステータス変更・分割・統合・振替・廃棄・返品・払出戻し)は `Api/Services/LotOperationService.cs`(実績CSVの種別 `inventory-operations` も同じメソッドを呼ぶ。`ActualCsvService.Inventory.cs`。分割・振替は呼び出し側のトランザクションがあればそれに乗る)
在庫数量の増減そのものは `Api/Services/InventoryService.cs` | Inventory.cs: InventoryStock / InventoryTransaction | `/inventory` `Inventory.razor` | `Tests/InventoryTests.cs` |
+| 出庫・ピッキング・工程払出(FEFO自動引当) | D-20-10 / D-20-20 | `PickingOrdersController.cs` `api/picking-orders`
(実績CSVの種別 `picking-orders`。番号は手入力)作成・実行・取消は `Api/Services/PickingService.cs` | Inventory.cs: PickingOrder / PickingLine | `/picking` `Picking.razor` | `Tests/InventoryTests.cs` |
| 出荷(出荷判定ゲート付き) | D-40 / H-10-10 | `ShippingOrdersController.cs` `api/shipping-orders`
作成・出荷実行・取消は `Api/Services/ShippingService.cs`
CSV:実績CSVの種別 `shipping-orders`・`shipments`(`ActualCsvService.Shipping.cs`。出荷番号は手入力必須、`SH〜` は拒否)。サンプルは `samples/actual-csv/13`〜`15` | Inventory.cs: ShippingOrder / ShippingLine | `/shipping` `Shipping.razor`
`/print/shipping/{id}` `Print/ShippingSlip.razor` | `Tests/InventoryTests.cs` |
-| 棚卸(スナップショット→実棚→差異→確定) | D-50-10 | `StocktakesController.cs` `api/stocktakes`
作成・実棚登録・確定・取消は `Api/Services/StocktakeService.cs` | Inventory.cs: Stocktake / StocktakeLine | `/stocktakes` `Stocktakes.razor`
`/print/stocktake/{id}` `Print/StocktakeSheet.razor` | `Tests/InventoryTests.cs` |
+| 棚卸(スナップショット→実棚→差異→確定) | D-50-10 | `StocktakesController.cs` `api/stocktakes`
(実績CSVの種別 `stocktakes`・`stocktake-counts`。番号は手入力)作成・実棚登録・確定・取消は `Api/Services/StocktakeService.cs` | Inventory.cs: Stocktake / StocktakeLine | `/stocktakes` `Stocktakes.razor`
`/print/stocktake/{id}` `Print/StocktakeSheet.razor` | `Tests/InventoryTests.cs` |
| ロケーション・棚番管理 | D-50-20-01 | `LocationsController.cs` `api/locations` | Masters.cs: Location(`WorkCenterId`=所属する資源。**段は問わない**) | `/masters` `Masters/LocationsTab.razor` | `Tests/MasterTests.cs` |
| 倉庫業務進捗管理(**受入は対象外**。指示を持たないため) | D-50-30-07 | `InventoryController.cs` `api/inventory/warehouse-progress?from=&to=`(出庫ピッキング・出荷・在庫移動・棚卸。取消は数えない。期間は**製造日の半開区間**) | 新規エンティティなし(既存の指示を数え直す) | `/warehouse-operations` `Web/Pages/WarehouseOperations.razor` | `Tests/InventoryTests.cs` |
-| サンプル品保管管理(採取時に**在庫から抜く**。保管棚は引当の対象にしない) | D-40-50-01 | `SampleStoragesController.cs` `api/sample-storages`(採取・払出/廃棄。期限判定は業務日付で取得後に埋める) | Inventory.cs: SampleStorage
`InventoryTransactionType.SampleRetention` | `/warehouse-operations` `Web/Pages/WarehouseOperations.razor` | `Tests/InventoryTests.cs` |
+| サンプル品保管管理(採取時に**在庫から抜く**。保管棚は引当の対象にしない) | D-40-50-01 | `SampleStoragesController.cs` `api/sample-storages`(採取・払出/廃棄は `Api/Services/SampleStorageService.cs`。実績CSVの種別 `sample-storages` と共通。期限判定は業務日付で取得後に埋める) | Inventory.cs: SampleStorage
`InventoryTransactionType.SampleRetention` | `/warehouse-operations` `Web/Pages/WarehouseOperations.razor` | `Tests/InventoryTests.cs` |
| 推奨ロケーション指示(**理由を添えて出すだけ**。強制しない) | D-10-30-03 / D-40-40-03 | `LocationsController.cs` `api/locations/recommendations?productId=`(①品目の既定ロケーション ②同じ品目の在庫がある場所 ③品目区分の既定エリア の順) | Masters.cs: Product.**DefaultLocationId**(既定の入庫先)
在庫の集計は InventoryStock | `/receiving` `Receiving.razor`
`ProductionRecordEntry.razor`(入庫先)
既定の設定は `Masters/ProductsTab.razor` | `Tests/MasterTests.cs` `Tests/MasterCsvTests.cs` |
## E. 設備保全
@@ -73,13 +73,13 @@
|:--|:--|:--|:--|:--|:--|
| 作業区/資源階層(BOR。工場/ライン/エリア/作業区) | I-10-20-02 | `WorkCentersController.cs` `api/work-centers`
`Api/Policies/WorkCenterHierarchyPolicy.cs`(段の妥当性・循環。単票APIとCSV取込の**両方**から呼ぶ) | Masters.cs: WorkCenter(自己参照。`Level` は文字列保存のため**DB側で並べると段の順にならない**。取得後に並べ直す) | `/masters` `Masters/WorkCentersTab.razor` | `Tests/MasterTests.cs` / `MasterCsvTests.cs` |
| 設備台帳/BOE・保全部品 | E-10-10 / I-10-20 | `EquipmentsController.cs` `api/equipments`
保全部品は `api/equipments/{id}/parts`(**設備ごとの一括置換**) | Masters.cs: Equipment(`WorkCenterId`=設置場所の正。**作業区(最下段)のみ**。`Site` は移行用の旧項目)
Masters.cs: **EquipmentPart**(品目参照。資産管理部品/消耗品の区分。`MaintenanceParts` の自由記述は移行元) | `/masters` `Masters/EquipmentsTab.razor` | `Tests/MasterTests.cs` |
-| 保全手順書(版数管理) | E-10-20 / E-20-30 | `MaintenanceProceduresController.cs` `api/maintenance-procedures`(**必要スキル**は保全実績の登録時に実施者と照合:`MaintenanceOrderService.AddRecordAsync` → `SkillQualificationPolicy`) | Maintenance.cs: MaintenanceProcedure | `/maintenance` `Maintenance/MaintenanceProceduresTab.razor` | `Tests/MaintenanceTests.cs` |
-| 保全計画(中長期・年次) | E-30-10 | `MaintenancePlansController.cs` `api/maintenance-plans`
取消(状態変更)は `Api/Services/MaintenanceOrderService.CancelPlanAsync` | Maintenance.cs: MaintenancePlan | `/maintenance` `Maintenance/MaintenancePlansTab.razor` | `Tests/MaintenanceTests.cs` |
-| 保全指示・実績・突発依頼 | E-30-20 / E-30-30 / E-40 | `MaintenanceOrdersController.cs` `api/maintenance-orders`
作成・実績登録・取消(指示と元計画の状態変更)は `Api/Services/MaintenanceOrderService.cs`
消費部材の在庫引落しは `POST {id}/record` の `parts`(**`InventoryService.RemoveAsync` 経由**。区分 `MaintenanceIssue`) | Maintenance.cs: MaintenanceOrder / MaintenanceRecord / **MaintenanceRecordPart**(ロット単位の消費明細。`PartsUsed` の自由記述は補足) | `/maintenance` `Maintenance/MaintenanceOrdersTab.razor` | `Tests/MaintenanceTests.cs` |
+| 保全手順書(版数管理) | E-10-20 / E-20-30 | `MaintenanceProceduresController.cs` `api/maintenance-procedures`(**必要スキル**は保全実績の登録時に実施者と照合:`MaintenanceOrderService.AddRecordAsync` → `SkillQualificationPolicy`)
CSV:マスタCSVの種別 `maintenance-procedures`(`MasterCsvService.Import.cs` の `ImportMaintenanceProceduresAsync`。取込の権限は `WriteRoles`=`MaintenanceManage`。取込欄は保全手順書タブ) | Maintenance.cs: MaintenanceProcedure | `/maintenance` `Maintenance/MaintenanceProceduresTab.razor` | `Tests/MaintenanceTests.cs` `Tests/MasterCsvTests.cs` |
+| 保全計画(中長期・年次) | E-30-10 | `MaintenancePlansController.cs` `api/maintenance-plans`
作成は `MaintenanceOrderService.CreatePlanAsync`(実績CSVの種別 `maintenance-plans` と共通。常に新規登録)、取消(状態変更)は `Api/Services/MaintenanceOrderService.CancelPlanAsync`
計画から指示を作るCSVは保全指示CSVの `FromPlan`(設備+予定日で未指示の計画が1件だけのとき) | Maintenance.cs: MaintenancePlan | `/maintenance` `Maintenance/MaintenancePlansTab.razor` | `Tests/MaintenanceTests.cs` |
+| 保全指示・実績・突発依頼 | E-30-20 / E-30-30 / E-40 | `MaintenanceOrdersController.cs` `api/maintenance-orders`
作成・実績登録・取消(指示と元計画の状態変更)は `Api/Services/MaintenanceOrderService.cs`
消費部材の在庫引落しは `POST {id}/record` の `parts`(**`InventoryService.RemoveAsync` 経由**。区分 `MaintenanceIssue`)
計画保全を作れるロールは `MaintenanceOrderService.PlannedOrderRoles`(単票APIとCSVで共有)
CSV:実績CSVの種別 `maintenance-orders`・`maintenance-records`(`ActualCsvService.Maintenance.cs`) | Maintenance.cs: MaintenanceOrder / MaintenanceRecord / **MaintenanceRecordPart**(ロット単位の消費明細。`PartsUsed` の自由記述は補足) | `/maintenance` `Maintenance/MaintenanceOrdersTab.razor` | `Tests/MaintenanceTests.cs`(CSVも) |
| 設備総合効率(OEE) | E-20-30-03 | `OeeController.cs` `api/oee`(**稼働サマリとは別**。あちらは稼働監視の素の時間区分、こちらは製造実績と突き合わせた評価指標)
負荷時間=記録済み総時間(**計画休止の区分は無い**)。理論CTは作業指示の標準作業時間(1個あたり) | Maintenance.cs: EquipmentLog(`WorkOrderId` が無い稼働は**対象外**=カバー率で開示)
Execution.cs: ProductionRecord | `/maintenance` `Maintenance/EquipmentLogsTab.razor`(稼働監視タブ) | `Tests/MaintenanceTests.cs` |
| 消耗材モニタリング | E-20-10-04 | `MaintenanceOrdersController.cs` `GET api/maintenance-orders/parts-consumption`(品目別の消費数量+現在庫。**期間は製造日基準**=保全実績の開始時刻が属する製造日) | Maintenance.cs: MaintenanceRecordPart | `/maintenance` `Maintenance/MaintenancePartsTab.razor`(消耗材モニタリングタブ) | `Tests/MaintenanceTests.cs` |
-| 治工具の引当・払出・受領確認 | B-20-30 | `ToolIssuesController.cs` `api/tool-issues`(引当/`{id}/issue` 払出・受領/`{id}/return` 返却/`{id}/cancel` 取消。`?openOnly=true` で現場に出ている分)
引当・払出・返却・取消は `Api/Services/ToolIssueService.cs`
可否判定は `Api/Policies/ToolIssuePolicy.cs`(**引当時と払出時の両方で通す**) | Maintenance.cs: ToolIssue(現物の所在。寿命の累計は ToolUsage 側で別物) | `/work-orders/{id}/setup` `WorkOrderSetup.razor`
一覧は `/tool-management` `ToolManagement.razor` | `Tests/MaintenanceTests.cs` |
-| 治工具マスタ・寿命管理・利用実績 | E-60 | `ToolsController.cs` `api/tools`(**使用中は引当から決まり手で付け外しできない**。判定は `Policies/ToolIssuePolicy.CheckManualStatus`、CSV取込も同じ)
`ToolUsagesController.cs` `api/tool-usages`(寿命分析は `GET life-analysis?from=&to=`。**使用実績のあった日**で割り、見込みが立たないものは null) | Masters.cs: Tool
Maintenance.cs: ToolUsage | `/masters` `Masters/ToolsTab.razor`
`/tool-management` `ToolManagement.razor` | `Tests/MaintenanceTests.cs` |
+| 治工具の引当・払出・受領確認 | B-20-30 | `ToolIssuesController.cs` `api/tool-issues`(引当/`{id}/issue` 払出・受領/`{id}/return` 返却/`{id}/cancel` 取消。`?openOnly=true` で現場に出ている分)
引当・払出・返却・取消は `Api/Services/ToolIssueService.cs`(実績CSVの種別 `tool-issues` も同じ。1行で払出・返却まで進める)
可否判定は `Api/Policies/ToolIssuePolicy.cs`(**引当時と払出時の両方で通す**) | Maintenance.cs: ToolIssue(現物の所在。寿命の累計は ToolUsage 側で別物) | `/work-orders/{id}/setup` `WorkOrderSetup.razor`
一覧は `/tool-management` `ToolManagement.razor` | `Tests/MaintenanceTests.cs` |
+| 治工具マスタ・寿命管理・利用実績 | E-60 | `ToolsController.cs` `api/tools`(**使用中は引当から決まり手で付け外しできない**。判定は `Policies/ToolIssuePolicy.CheckManualStatus`、CSV取込も同じ)
`ToolUsagesController.cs` `api/tool-usages`(登録は `ShopFloorReportService.AddToolUsageAsync`。実績CSVの種別 `tool-usages` と共通。記録日時を指定できるのはCSVだけ。寿命分析は `GET life-analysis?from=&to=`。**使用実績のあった日**で割り、見込みが立たないものは null) | Masters.cs: Tool
Maintenance.cs: ToolUsage | `/masters` `Masters/ToolsTab.razor`
`/tool-management` `ToolManagement.razor` | `Tests/MaintenanceTests.cs` |
## F. 従業員管理
@@ -115,7 +115,7 @@
| クライアントのエラー処理 | `Web/Layout/MainLayout.razor`(`ErrorBoundary`)
`Web/Auth/AuthMessageHandler.cs`(401時のリフレッシュ→失敗ならログイン画面へ)
`Web/Auth/AuthService.cs`(`EndSession`) | 画面のGET失敗でアプリ全体が操作不能にならないようにする。書き込み系は各画面の`_error` + `` が担当(従来どおり) |
| 多言語対応(日本語・英語) | `Core/Constants/MesCultures.cs`(対応言語)
`Core/Localization/EnumLabels.cs`(区分値・ロールの表示名。訳は `CoreText.en.resx`)
`Web/Shared/UiText.en.resx`(画面の訳。全画面に `L` を注入:`_Imports.razor`)/ `AppCulture.cs` / `CultureSelector.razor`
`Api/Localization/ApiText.cs`(`ApiText.T` で利用者向けの文言を訳す。訳は `ApiText.en.resx`)/ `MesAppHost.cs`(`UseRequestLocalization`) | Spec.md 7.9。キーは日本語の原文。進捗は `docs-dev/I18nPlan.md`。`Tests/AuthTests.cs`・`Web.Tests/CultureTests.cs` |
| 監査ログ | `Core/Abstractions/IAuditLogger.cs`
`Infra/Services/AuditLogger.cs`
`Core/Entities/AuditLog.cs` | `Api/Controllers/AuditLogsController.cs` `api/audit-logs`(参照専用・システム管理者のみ)
`/audit-logs` `Web/Pages/AuditLogs.razor`
`Tests/AuditLogTests.cs`。Spec.md 7.6。記録日(`RecordedOn`)は**工場のローカル暦日**(`BusinessDay:TimeZone`)で、製造日の6時境界は当てない(保持期間の境界も同じ暦日)。**全ての書き込み系アクションで呼ぶ**。変更前後を追跡する操作は `detail:` に匿名オブジェクト(`{ before, after, reason }`)を渡す=JSON保存。要約でよい操作は文字列のまま |
-| 採番(指図番号・ロット番号等) | `Api/Services/NumberingService.cs`
`Core/Entities/NumberSequence.cs` | Spec.md 3.9・4章。生SQLの表名・列名はプロバイダーの引用符で囲み、一意制約違反の再試行はセーブポイントへ戻してから(PostgreSQL対策)。新しい採番区分はここに追加。払い出しは採番テーブルの1行を更新してから読む(最大値+1にしない)。**変更追跡を使わない**(呼び出し側の未確定の変更を書き込まないため`ExecuteUpdate`と生SQL)。`Tests/InventoryTests.cs` |
+| 採番(指図番号・ロット番号等) | `Api/Services/NumberingService.cs`
`Core/Entities/NumberSequence.cs` | Spec.md 3.9・4章。生SQLの表名・列名はプロバイダーの引用符で囲み、一意制約違反の再試行はセーブポイントへ戻してから(PostgreSQL対策)。新しい採番区分はここに追加。**自動採番の接頭辞(`OrderNoPrefix` 等)と手入力番号の判定 `IsAutoNumberFormat` もここ**(CSV取込で後続の行から指す番号を手入力させるサービス:製造指図・出荷指示・ピッキング・棚卸・不適合・保全指示。接頭辞で始まる番号は入力不正、重複は競合)。払い出しは採番テーブルの1行を更新してから読む(最大値+1にしない)。**変更追跡を使わない**(呼び出し側の未確定の変更を書き込まないため`ExecuteUpdate`と生SQL)。`Tests/InventoryTests.cs` |
| ロット使用可否(投入・引当・出荷の共通判定) | `Api/Policies/LotUsabilityPolicy.cs` | Spec.md 3.9。ステータス・有効期限の条件は**ここだけ**に置く。呼び先は `WorkOrderExecutionService.AddConsumptionAsync`(投入)/`InventoryService.AllocateFefoAsync`(FEFO)/`ShippingService.ShipAsync`(出荷) |
| 製造条件の逸脱判定 | `Api/Policies/ControlItemDeviationPolicy.cs` | Spec.md 5.7。基準は**マスタ現在値ではなく作業指示のスナップショット**(`WorkOrderControlItem`)。数値なし・上下限なしは判定せず`null`のまま(`false`にしない)。呼び先は `WorkOrderExecutionService.AddDataRecordsAsync` |
| 直(シフト)の時間帯判定 | `Api/Policies/ShiftSchedulePolicy.cs` | Spec.md 5.7。日跨ぎ(`EndTime <= StartTime`)・重なり判定・時刻→直の解決・**製造日の境界またぎの警告**(`CheckBusinessDateBoundary`。拒否ではなく `ShiftResponse.BoundaryWarning` で返す)。単票APIとCSV取込の両方から通す。重なり判定は**0時で切って区間に開いてから突き合わせる**(開始・終了の大小比較だと 22:00〜06:00 と 05:00〜09:00 の重なりを見落とす)。判定の単体テストは `Tests/ShiftSchedulePolicyTests.cs`(1440分の総当たりを期待値にした網羅テストで等価性を固定) |
diff --git a/docs-dev/CsvCoverageTasks.md b/docs-dev/CsvCoverageTasks.md
new file mode 100644
index 0000000..a837a56
--- /dev/null
+++ b/docs-dev/CsvCoverageTasks.md
@@ -0,0 +1,183 @@
+# 画面からしか登録できない機能のCSV対応 バックログ
+
+サンプルCSVだけでアプリ全体を一通り試せるように、また試験・移行で記録を一括投入できるように、
+今は画面(単票API)からしか登録できない業務記録をCSVで取り込めるようにする(Spec.md 3.8)。
+1カード=1セッションで進める(`Orchestration.md` §4.1)。カードの間はセッションをクリアし、次へ進む前に前のカードをコミットする。
+新しいセッションでは「`docs-dev/CsvCoverageTasks.md` の CSV-0x を実行して」とだけ頼めば着手できるように書いてある。
+
+## 背景
+
+2026-09-23 のサンプル点検で、次の機能はCSVの種別が無く、サンプル取込後に画面で一から入力するしかないと分かった
+(サンプルの不足 A1〜A6 は PR #28 で対応済み。こちらはその残り「B」の根本対応)。
+
+保全手順書・保全計画・保全指示/実績・突発依頼、治工具の利用実績・引当、校正記録、在庫オペレーション(移動・調整・状態変更・分割・統合・振替・廃棄・返品・工程戻し)、
+工程間搬送、ピッキング、棚卸、不適合、サンプル品保管。
+
+## 決定事項(2026-09-23)
+
+| 論点 | 決定 | 理由 |
+|:--|:--|:--|
+| 取込の型 | 既存の実績CSV(常に新規登録・1行でもエラーなら全件取消・ZIP一括可)に乗せる。保全手順書だけはマスタCSV(キーで上書き・出力あり) | 保全手順書は作業手順書(`work-procedures`)と同じ版数付きのマスタ。ほかは日々増える業務記録 |
+| 業務判定 | **行ごとに単票APIと同じ業務サービスを呼ぶ**(既存の実績CSVと同じ)。登録処理が Controller に直接書かれている箇所は、先にサービスへ移す | CSVと画面で判定がずれないようにする |
+| 番号のある記録 | 番号を**手入力**にして、後のCSVから指せるようにする。自動採番の接頭辞で始まる番号は拒否する(出荷指示の `ShippingNo`・製造指図の `OrderNo` と同じ) | 自動採番の番号は取り込むまで分からない |
+| 番号の無い記録 | 搬送・治工具の引当などは、1行の中の列(`Execute`・`Issue`・`Return` など)で実行・返却まで進め、ほかのファイルからは指さない。**番号の列を足すマイグレーションは作らない** | データモデルを変えずに済む |
+| 在庫オペレーション | **1種別 `inventory-operations`** に `Operation` 列を持たせる。操作ごとの必須列はコードで検査する | 操作ごとに分けると9種別になり、実績CSV取込の画面にパネルが9枚増える |
+| 自動起票された不適合の指し方 | **ロット番号で指し、そのロットの未完了の不適合が1件だけのときに限る**(0件・2件以上は行エラー)。新規起票の行は `ReportNo` を手入力 | 検査の不合格で自動起票された不適合は番号が取り込むまで分からない |
+| 保全計画 | **実績CSV(常に新規登録)で行う方針。ただし実装は保留**(下の「保留」) | 自然なキーが無く、生産計画のようなキーで上書きの形にできない |
+| 取消 | 取消(キャンセル)の操作はCSVの対象にしない | 取込は記録を作るためのもので、取り消しは画面で1件ずつ確かめて行う |
+| 設計変更 | 対象外。工順の改訂は既存の工順CSV(`routing`)で入る | ― |
+| 権限 | 種別ごとの取込ロールは単票APIと同じ定数(`ActualCsvKinds` の `WriteRoles`) | 既存の方針 |
+
+## バックログ
+
+| ID | 内容 | 想定層 | 状態 |
+|:--|:--|:--|:--|
+| CSV-01 | 下準備:実績CSVサービスの分割と、手入力番号の受け口 | L1 | 完了 |
+| CSV-02 | 保全手順書のマスタCSV | L1 | 完了 |
+| CSV-03 | 保全指示・突発依頼・保全実績 | L1 | 完了 |
+| CSV-04 | 治工具の利用実績・引当、校正記録 | L1 | 完了 |
+| CSV-05 | 在庫オペレーション | L1 | 完了 |
+| CSV-06 | 搬送・ピッキング・棚卸 | L1 | 完了 |
+| CSV-07 | 不適合・サンプル品保管 | L1 | 完了 |
+| CSV-08 | サンプルの通し取込とユーザーガイドの見直し | L2 | 完了 |
+| (保留→実施) | 保全計画 `maintenance-plans` | L1 | 完了 |
+
+### 各カード共通のDoD
+
+- ビルド成功・新規警告なし、`dotnet test` 全緑
+- 新しい種別は `GET api/actuals/csv/kinds`(マスタは `api/masters/csv/kinds`)に出て、実績CSV取込画面にパネルが自動で並ぶ(画面のコードは変えない)
+- 列の説明・エラー文言の英訳を `Api/Localization/ApiText.en.resx` に足し、`./scripts/I18nCheck.ps1` が終了コード0
+- `Spec.md` 3.8節の種別一覧と改訂行、`docs-dev/CodeMap.md` の該当行、`docs/masters.md` の実績CSVの節を同じコミットで更新
+- サンプルCSV(`samples/actual-csv/` の次の番号)とREADMEを足し、`MasterCsvTests` の通し取込(ファイル名順・ZIP一括)が通る。ZIPテストのファイル数も直す
+- 領域の既存テストクラスに「取り込める」「1行エラーで全件取消」「単票APIと同じ判定で拒否される」を足す
+- 状態表とメモ(`csv-coverage-backlog`)を更新
+
+---
+
+## タスク: CSV-01 下準備:実績CSVサービスの分割と手入力番号の受け口
+
+- 目的 / 背景: `ActualCsvService.cs`(755行)にこの先12種別を足すと読めなくなる。領域ごとの partial に分けてから足す。あわせて、番号を自動採番しているサービスに手入力の番号を渡せる引数を足す
+- 変更対象:
+ - `src/MesApp.Api/Services/ActualCsvService.cs` → `ActualCsvService.Production.cs`・`.Execution.cs`・`.Quality.cs` などに分割(`.Shipping.cs` と同じ形。**挙動は変えない**)
+ - `PickingService.CreateAsync`・`StocktakeService.CreateAsync`・`NonconformanceService.CreateAsync`・`MaintenanceOrderService.CreateAsync` に `string? no` 引数(空なら今までどおり自動採番。自動採番の接頭辞で始まる番号・既存と重複する番号は拒否)
+ - 接頭辞の判定を `NumberingService` に集める(今は `ManufacturingOrderService.AutoOrderNoPrefix` と出荷で別々)
+ - 呼び出し側の Controller(引数 `null` を渡すだけ)
+- 変更禁止: 既存のCSVの列・結果・エラー文言、API契約、Migrations
+- 受入条件: 既存テストが無変更で全緑。`ActualCsvService` のどのファイルも500行以下。手入力番号の拒否(接頭辞・重複)をサービス単位でテスト
+- 参照: Spec.md 3.8・3.9(採番)/ CodeMap「実績CSV一括取込」「採番」
+- 想定層: L1 / effort: xhigh(CSV取込・採番を触る)
+- 想定変更ファイル数: 約10
+
+## タスク: CSV-02 保全手順書のマスタCSV
+
+- 目的 / 背景: 保全手順書(E-10-20)だけがマスタなのにCSVで入出力できない
+- 変更対象: `MasterCsvKinds.cs`(種別 `maintenance-procedures`。`ImportOrder` では設備・治工具・スキルの後)、`MasterCsvService.cs`(出力)、`MasterCsvService.Import.cs` またはその partial(取込。版数の扱いは `work-procedures` と同じ)、`Pages/Maintenance/MaintenanceProceduresTab.razor`(`` を置く)、`samples/master-csv/20_maintenance-procedures.csv`・README、`MasterCsvTests`
+- 列: `ProcedureNo`(キー)/ `Title` / `TargetEquipmentAssetNo` / `TargetToolCode` / `RequiredSkillCode` / `Steps` / `IsActive`(`Version` は出力専用)
+- 変更禁止: 保全手順書のAPI契約、版数の付け方
+- 受入条件: 取り込み直しても件数が増えない。本文が変わったときだけ版数が上がる(作業手順書と同じ)。参照中の手順書を `IsActive=false` にすると `MasterDeactivationPolicy` と同じ理由で拒否。マスタZIPの一括出力に入り、別DBへそのまま取り込める(既存テストの種別網羅チェックが通る)
+- 参照: Spec.md 3.1・3.8 / MES.md E-10-20 / CodeMap「保全手順書」「マスタCSV一括入出力」
+- 想定層: L1 / effort: xhigh
+- 想定変更ファイル数: 約8
+- 実施結果(2026-09-24。Spec.md 改訂137):受入条件のうち2点は単票APIに合わせて変えた。①版数は**更新のたびに上がる**(作業手順書・単票の更新と同じ。本文の比較はしない)。②単票APIに無効化の制約が無いため、**参照中でも無効化を拒否しない**(`MasterDeactivationPolicy` に保全手順書の判定は無い)。`Version` 列は作業手順書と同じく出力しない。取込の権限は `MaintenanceManage`
+
+## タスク: CSV-03 保全指示・突発依頼・保全実績
+
+- 目的 / 背景: 保全指示・実績(E-30-20 / E-30-30 / E-40)が画面からしか入らず、消耗材モニタリング・保全の履歴を試せない
+- 変更対象: `ActualCsvKinds.cs`、`ActualCsvService.Maintenance.cs`(新規)、`MaintenanceOrderService`(CSV-01 の番号引数を使う)、サンプル・README、`MaintenanceTests`
+- 種別:
+ - `maintenance-orders`:`MaintenanceNo`(手入力・必須)/ `EquipmentAssetNo` か `ToolCode` / `RequestType`(計画・突発など)/ `ProcedureNo` / `ScheduledDate` / `Note`
+ - `maintenance-records`:`MaintenanceNo` / `StartedAt` / `EndedAt` / `Result` / `Note` / 部品 `PartProductCode`・`PartLotNumber`・`PartLocationCode`・`PartQuantity`(**同じ番号・同じ開始日時の行を1件の実績にまとめる**。部品の在庫引落しは `AddRecordAsync` のまま)
+- 変更禁止: 保全の在庫引落しの経路(新設しない。メモ [[maintenance-parts-design]])、必要スキルの照合(実施者=取り込んだユーザー)
+- 受入条件: 資産管理部品と消耗品の書き分けが単票と同じに通る。必要スキルを持たないユーザーの取込は拒否。部品の在庫不足は行エラーで全件取消
+- 参照: Spec.md 3.8・5.x(保全)/ MES.md E-30-20・E-30-30・E-40 / CodeMap「保全指示・実績・突発依頼」「消耗材モニタリング」
+- 想定層: L1 / effort: xhigh(在庫更新を含む)
+- 想定変更ファイル数: 約8
+- 実施結果(2026-09-24。Spec.md 改訂138):実績は登録で指示が完了し1件の指示に1件だけなので、まとめるキーは**保全指示番号だけ**にした(開始日時は見ない)。`PartProductCode` 列は持たない(ロット番号で品目が決まる)。計画保全は単票と同じく保全担当者だけ(`MaintenanceOrderService.PlannedOrderRoles`。取り込んだユーザーのロールをDBで引く)。保全計画からの指示(`MaintenancePlanId`)は保留に合わせて対象外。サンプルは 18・19 と、`01_receiving.csv` に保全部品2ロット(番号指定)を追加
+
+## タスク: CSV-04 治工具の利用実績・引当、校正記録
+
+- 目的 / 背景: 治工具の寿命(E-60)・引当(B-20-30)・校正(C-20-50-03)がサンプルで試せない
+- 変更対象:
+ - 利用実績の登録を `ToolUsagesController` からサービスへ移す(`ToolIssueService` に足すか `ToolUsageService` を新設。**調査で確定**)
+ - 校正記録の登録を `InspectionDevicesController`(`{id}/calibrations`)からサービスへ移す
+ - `ActualCsvKinds.cs`、`ActualCsvService.Tools.cs`(新規)、サンプル・README、`MaintenanceTests`・`QualityTests`
+- 種別:
+ - `tool-usages`:`ToolCode` / `OrderNo`+`Sequence`(任意)/ `UsageCount` / `UsageHours` / `RecordedAt`
+ - `tool-issues`:`ToolCode` / `OrderNo`+`Sequence` / `Issue`(true で払出まで)/ `Return`(true で返却まで)/ `Note`。判定は `ToolIssuePolicy`(引当時・払出時の両方)
+ - `calibrations`:`DeviceCode` / `CalibratedOn` / `Result` / `NextDueOn`(省略時は周期から)/ `Note`
+- 変更禁止: 治工具の状態を手で付け外しする規則(`ToolIssuePolicy.CheckManualStatus`)、API契約
+- 受入条件: 使用中の治工具を引き当てる行は拒否。校正の記録で検査機の次回期限が更新され、DV-03 に合格の校正を入れると検査に使えるようになる
+- 参照: Spec.md 3.8 / MES.md E-60・B-20-30・C-20-50-03 / CodeMap「治工具の引当・払出・受領確認」「治工具マスタ・寿命管理・利用実績」「検査機・測定器マスタ/校正管理」
+- 想定層: L1 / effort: xhigh
+- 想定変更ファイル数: 約10(3ファイル超。重ければ「治工具」と「校正」の2つに分ける)
+- 実施結果(2026-09-24。Spec.md 改訂139):利用実績は `ShopFloorReportService.AddToolUsageAsync`(現場の記録で、ロールで絞らない点も同じ)、校正は新設の `InspectionDeviceService` に移した。引当の取込ロールは `ProductionManage`(払出・返却の `ShopFloorRecord` を含む)。校正CSVに `Note` 列は無い(単票の要求に備考が無いため)。`RecordedAt` はCSVだけで指定でき、メンテの寿命リセット(取込時刻)より前の日時は累計に入らないので、サンプル 19 の TL-01 メンテは寿命をリセットしないようにした
+
+## タスク: CSV-05 在庫オペレーション
+
+- 目的 / 背景: 在庫の移動・調整・分割・統合などが画面からしか行えない
+- 変更対象: `ActualCsvKinds.cs`、`ActualCsvService.Inventory.cs`(新規)、サンプル・README、`InventoryTests`
+- 種別 `inventory-operations`:`Operation`(`Move` / `Adjust` / `Status` / `Split` / `Merge` / `Transfer` / `Discard` / `Return` / `IssueReturn`)/ `LotNumber` / `LocationCode` / `ToLocationCode` / `Quantity` / `TargetLotNumber`(統合先)/ `ProductCode`(振替先)/ `Status` / `Reason`。操作ごとの必須列はコードで検査し、足りなければ「操作 '{0}' には {1} が必要です。」の行エラー。各行は `LotOperationService` の同名メソッドを呼ぶ
+- 変更禁止: `LotOperationService` の判定、ロット系譜・状態履歴の残し方、ステータスの直接代入の禁止
+- 受入条件: 分割で作られたロットを同じファイルの後の行から指せる(分割先のロット番号を列で指定。**指定できない場合は調査で確定**)。調整・状態変更の理由が状態履歴・監査ログに残る。不良ロットの移動など単票で拒否されるものはCSVでも拒否
+- 参照: Spec.md 3.8・5.2 / MES.md D-10-30・D-30-10・D-40-40 / CodeMap「在庫オペレーション」「ロット系譜」
+- 想定層: L1 / effort: xhigh(在庫更新)
+- 想定変更ファイル数: 約6
+- 実施結果(2026-09-24。Spec.md 改訂140):分割先のロット番号は `NewLotNumber` 列で指定できる(単票の要求にもある)。統合先は `TargetLotNumber`、払出戻しの作業指示は `OrderNo`+`Sequence`。`Operation` は英語名のほか日本語(移動・分割など)も受け付ける。分割・振替は `LotOperationService.DeriveAsync` が自前でトランザクションを張っていて取込の外側のトランザクションと衝突したため、既にあればそれに乗るようにした(判定は変えていない)。単票の移動は不良ロットを拒否していなかった(カードの前提と違う)ので、CSVも同じく拒否しない
+
+## タスク: CSV-06 搬送・ピッキング・棚卸
+
+- 目的 / 背景: 物流の指示系(B-50-10 / D-20 / D-50-10)と倉庫業務進捗(D-50-30-07)がサンプルで空のまま
+- 変更対象:
+ - 搬送の作成・実行を `TransferOrdersController` からサービスへ移す(`InventoryService` に足すか `TransferOrderService` を新設。**調査で確定**)
+ - `ActualCsvKinds.cs`、`ActualCsvService.Logistics.cs`(新規)、`PickingService`・`StocktakeService`(CSV-01 の番号引数)、サンプル・README、`InventoryTests`
+- 種別:
+ - `transfer-orders`:`LotNumber` / `FromLocationCode` / `ToLocationCode` / `Quantity` / `Execute`
+ - `picking-orders`:`PickingNo`(手入力・必須)/ `OrderNo`+`Sequence` / `Execute`(FEFO の自動引当は単票と同じ)
+ - `stocktakes`:`StocktakeNo`(手入力・必須)/ 範囲(`LocationCode` など。**単票の作成要求に合わせて調査で確定**)
+ - `stocktake-counts`:`StocktakeNo` / `LotNumber` / `LocationCode` / `CountedQuantity` / `Finalize`(まとまりの最初の行)
+- 変更禁止: FEFO 引当の規則、棚卸の差異計算
+- 受入条件: 期限切れロット(サンプルの R3006-250801)はピッキングの引当に入らない。棚卸の確定で差異が在庫調整として残る。倉庫業務進捗に指示が出る
+- 参照: Spec.md 3.8 / MES.md B-50-10・D-20-10・D-20-20・D-50-10・D-50-30-07 / CodeMap「工程間搬送・移動指示」「出庫・ピッキング・工程払出」「棚卸」「倉庫業務進捗管理」
+- 想定層: L1 / effort: xhigh(在庫更新)
+- 想定変更ファイル数: 約10(3ファイル超。重ければ「搬送・ピッキング」と「棚卸」の2つに分ける)
+- 実施結果(2026-09-24。Spec.md 改訂141):搬送は `TransferOrderService` を新設(作成・実行・取消。取消も状態を Controller で代入しないため移した)。棚卸指示の範囲は単票の作成要求と同じ `LocationCode`(空欄は全ロケーション)だけ。ピッキングは番号が同じ行を1件の指示にまとめ、`Type`(工程払出・出荷)と払出先(`OrderNo`+`Sequence` か `ShippingNo`)はまとまりの最初の行の値。実棚数は明細をロット番号+ロケーションで指す
+
+## タスク: CSV-07 不適合・サンプル品保管
+
+- 目的 / 背景: 不適合の対応(C-30)と保管サンプル(D-40-50-01)が画面からしか進められない
+- 変更対象:
+ - サンプルの採取・払出/廃棄を `SampleStoragesController` からサービスへ移す(`SampleStorageService` を新設)
+ - `ActualCsvKinds.cs`、`ActualCsvService.Quality.cs`、`NonconformanceService`(CSV-01 の番号引数)、サンプル・README、`QualityTests`・`InventoryTests`
+- 種別:
+ - `nonconformances`:`ReportNo`(新規起票のとき手入力)**または** `LotNumber`(既存の不適合を指す。**そのロットの未完了の不適合が1件だけのときに限る**。0件・2件以上は行エラー)/ 新規時の `Content`・`OrderNo`+`Sequence` / `CauseCategory`・`CauseDetail` / `Action`・`ActionInstruction`(対応指示)/ `ActionRecord`(対応記録)/ `Approve`
+ - `sample-storages`:`SampleNo`(手入力・必須)/ `LotNumber` / `Quantity` / `StorageLocationCode` / `RetentionUntil` / `Close`(払出・廃棄の区分)
+- 変更禁止: 不適合の連鎖(リワーク指図の起票など `NonconformanceService` の判定)、保管棚を引当の対象にしない規則
+- 受入条件: サンプルの R3001-260902(検査不合格で自動起票)をロット番号で指して対応指示→承認まで進められる。同じロットに未完了の不適合が2件あると行エラー。サンプル採取で在庫から抜かれる
+- 参照: Spec.md 3.8 / MES.md C-30・B-40-30・D-40-50-01 / CodeMap「不適合・逸脱管理」「サンプル品保管管理」
+- 想定層: L1 / effort: xhigh
+- 想定変更ファイル数: 約9(3ファイル超。重ければ「不適合」と「サンプル保管」の2つに分ける)
+- 実施結果(2026-09-24。Spec.md 改訂142):`ReportNo` は登録済みならその不適合を指し、未登録なら起票する(別ファイルから番号で進められるように)。対応指示・承認は単票と同じ品質管理だけで、行ごとに取り込んだユーザーのロールをDBで引く(起票・対応記録は誰でも)。サンプルは `SampleStorageService` を新設し、サンプル番号の手入力(`SP〜` は拒否)もここで受ける。登録済みのサンプル番号の行は `Close` だけを行う。既存の不適合を指す行の起票用の列(内容・原因)は使わない
+
+## タスク: CSV-08 サンプルの通し取込とユーザーガイドの見直し
+
+- 目的 / 背景: CSV-02〜07 で足したサンプルを、マスタ→実績のZIP一括取込で最後まで通し、ユーザーガイドの「画面から操作すること」を実態に合わせる
+- 変更対象: `samples/actual-csv/README.md`(全体の流れ)、`docs/walkthrough.md`(10章の表)・`docs/getting-started.md`、`MasterCsvTests`(通し取込の確認項目)
+- 変更禁止: アプリのコード
+- 受入条件: マスタZIP→実績ZIPの順で1回ずつ取り込むだけで、保全・治工具・校正・在庫・物流・不適合・サンプル保管の各画面に記録が出る(テストで各APIの件数>0を確認)。`jekyll build` が通る
+- 参照: CodeMap「実績CSV一括取込」
+- 想定層: L2 / effort: medium
+- 想定変更ファイル数: 約5
+- 実施結果(2026-09-24):通し取込の確認は `MasterCsvTests` の「サンプルのマスタと実績をそれぞれZIPで一括取込できる」に集め、CSV-02〜07 で足した各一覧が空でないことを確かめる。`docs/walkthrough.md` 10章の表に保全・治工具と校正・在庫と物流・不適合とサンプルの4行を足し、「画面から操作すること」を残した指示(計画保全・搬送・出荷ピッキング・棚卸・特採)に合わせた
+
+---
+
+## 保留
+
+### 保全計画 `maintenance-plans`(2026-09-23 利用者の判断で実装保留 → 2026-09-24 実施。Spec.md 改訂143)
+
+- 実施結果:計画の指し方は**設備+予定日**(未指示の計画が1件だけのとき。0件・2件以上は行エラー)。保全指示CSVの `FromPlan=true` で指し、依頼区分は計画になる。登録は `MaintenanceOrderService.CreatePlanAsync` に移した。計画年度は省略すると予定日の年。サンプルは `18_maintenance-plans.csv` を足し、以降を 19〜30 に繰り下げた
+
+- 方針だけ決めてある:**実績CSV(常に新規登録)**。列は `EquipmentAssetNo` / `Category` / `PlanYear` / `ScheduledDate` / `CycleDays` / `Note`
+- マスタCSV(キーで上書き)にしない理由:設備×年×区分でも同じ設備に同じ区分の計画が複数ありうるので、自然なキーが無い
+- 下準備:登録処理が `MaintenancePlansController.Create` に直接書かれているので、実装するときは先に `MaintenanceOrderService` へ移す
+- 計画から保全指示を作る流れ(CSV-03 の `maintenance-orders` から計画を指す)は、計画に番号が無いため指せない。着手するときに、計画の指し方(設備+年+区分+予定日の一致など)を決める
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 904f3ab..77ec56b 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -131,7 +131,7 @@ dotnet run --project src/MesApp.Api
業務を始めるにはマスタ登録が必要です。特に **工程 → 品目 → 工順(BOP)** の順で登録しないと
製造指図を工程展開できません。[マスタ管理](masters.html)へ進んでください。
-まず動きを試したい場合は、サンプルデータを入れると製造指図から出荷まで操作できる状態になります。
+まず動きを試したい場合は、サンプルデータを入れると製造指図から出荷までと、保全・治工具・在庫・物流・不適合の記録がそろった状態になります。
[マスタのサンプル](https://github.com/msmsrep/ParallelFactoryMES/releases/latest/download/samples-master-csv.zip)を
**マスタ管理**の「ZIPで一括取込」で、続けて[実績のサンプル](https://github.com/msmsrep/ParallelFactoryMES/releases/latest/download/samples-actual-csv.zip)を
**管理 → 実績CSV取込**の「ZIPで一括取込」で取り込みます([マスタ管理のCSV](masters.html#csv))。
diff --git a/docs/maintenance.md b/docs/maintenance.md
index 757bc4a..e29c08b 100644
--- a/docs/maintenance.md
+++ b/docs/maintenance.md
@@ -122,6 +122,10 @@ next_title: 帳票・ラベル・スキャン
内容を見直したときは上書きではなく登録し直してください。
+手順書をまとめて登録・見直しするときは、タブの **CSV入出力** から取り込めます。
+対象設備は資産番号、対象治工具と必要スキル・資格はコードで書きます。既存の手順書番号の行は更新になり、版数が1つ上がります。
+書き方は[マスタ管理のCSV](masters.html)と同じで、サンプルは `samples/master-csv/20_maintenance-procedures.csv` です。
+
---
## 治工具管理 {#tool-management}
diff --git a/docs/masters.md b/docs/masters.md
index 1263aae..c5acf28 100644
--- a/docs/masters.md
+++ b/docs/masters.md
@@ -262,7 +262,7 @@ MBOMは、実績入力の**バックフラッシュ**(部材の自動消費)
製造指図から出荷まで一通り試せる状態になります。
**[samples-master-csv.zip](https://github.com/msmsrep/ParallelFactoryMES/releases/latest/download/samples-master-csv.zip)**
-(作業区・工程・ロケーション・品目・スキル・勤務シフト・設備・保全部品・治工具・チェックリスト・不良理由・検査項目・工程管理項目・検査機・MBOM・作業手順書・工順・ユーザー)。
+(作業区・工程・ロケーション・品目・スキル・勤務シフト・設備・保全部品・治工具・チェックリスト・不良理由・検査項目・工程管理項目・検査機・MBOM・作業手順書・工順・ユーザー・保全手順書)。
下の「ZIPでまとめて取り込む」にそのまま渡せます。中身のCSVは [samples/master-csv](https://github.com/msmsrep/ParallelFactoryMES/tree/main/samples/master-csv) でも見られます。
まずはこのサンプルを取り込んで動きを確認し、エクスポートしたCSVを自社の内容に
@@ -317,16 +317,16 @@ CSVに登場しなかった親品目のMBOM・工順は変更されません。
## 実績のCSV取込 {#actual-csv}
-試験・移行・教育用に、**実績(受入・製造指図・段取り・チェックリスト・部材投入・生産実績・製造条件・検査・作業時間・トラブル報告・設備稼働記録・出荷指示・出荷判定・出荷実行)もCSVでまとめて登録できます**。
+試験・移行・教育用に、**実績(受入・製造指図・段取り・チェックリスト・部材投入・生産実績・製造条件・検査・作業時間・トラブル報告・設備稼働記録・出荷指示・出荷判定・出荷実行・保全計画・保全指示・保全実績・治工具の利用実績と引当・検査機の校正・在庫オペレーション・搬送・ピッキング・棚卸・不適合・サンプル品保管)もCSVでまとめて登録できます**。
メニューの **管理 → 実績CSV取込** を開き、種別ごとのパネルから取り込みます。
- 1件ずつ画面から登録したときと**同じチェック**を通ります(校正期限切れの検査機、理由の無い代替部品の投入、在庫不足などは取り込めません)。
在庫・ロットの状態・監査ログも同じように記録されます。
- **取込は常に新規登録**です。同じファイルを2回取り込むと、2回分の実績になります。
-- 作業指示は **指図番号+工程順序** で、投入・検査・出荷するロットは **ロット番号** で、出荷指示は **出荷番号** で指します。
- 製造指図の指図番号、後で投入するロットのロット番号、出荷指示の出荷番号は、CSVで指定しておいてください(自動採番の番号は取り込むまで分かりません)。
+- 作業指示は **指図番号+工程順序** で、投入・検査・出荷するロットは **ロット番号** で、出荷指示は **出荷番号** で、保全指示は **保全指示番号** で、棚卸は **棚卸番号** で、不適合は **不適合番号** か **ロット番号**(そのロットの未完了の不適合が1件だけのとき)で指します。
+ 製造指図の指図番号、後で投入するロットのロット番号、出荷指示の出荷番号、保全指示の保全指示番号、ピッキング・棚卸の番号は、CSVで指定しておいてください(自動採番の番号は取り込むまで分かりません)。
- **記録者は取り込んだユーザー**です。作業者を指定する列はありません。
-- 取り込める権限は、種別ごとに画面から登録するときと同じです(受入・出荷指示・出荷実行は物流、製造指図は生産管理、検査は品質管理、出荷判定は品質保証 など)。
+- 取り込める権限は、種別ごとに画面から登録するときと同じです(受入・出荷指示・出荷実行は物流、製造指図は生産管理、検査は品質管理、出荷判定は品質保証 など。保全の突発依頼と実績・治工具の利用実績は誰でも、保全計画と計画保全の指示は保全担当者だけ、治工具の引当は生産管理、検査機の校正は品質管理)。
- 「1行でもエラーがあると全件取り消し」「検証のみ」はマスタのCSVと同じです。
- 画面の上の **ZIPで一括取込** で、複数のCSVを1回で取り込めます(ファイル名の順。1ファイルでもエラーがあればZIPの全ファイルを取り消し)。
diff --git a/docs/walkthrough.md b/docs/walkthrough.md
index 8e96acf..ca28195 100644
--- a/docs/walkthrough.md
+++ b/docs/walkthrough.md
@@ -498,12 +498,16 @@ FG1000-S001 の履歴タブ(製造・検査・在庫の記録)を開けば
| 段階 | サンプルCSVで入るもの | 画面から操作すること |
|---|---|---|
-| マスタ | `samples/master-csv` の19ファイル(作業区〜ユーザーのスキル) | ― |
-| 受入 | `01_receiving.csv`(部材7ロット・保全部品1ロット) | ― |
-| 製造指図 | `02_manufacturing-orders.csv`(SMP-SF-001・SMP-FG-001 は展開済み、SMP-FG-002 は承認済み、SMP-FG-003 は未承認) | SMP-FG-002 の **工程展開** と **差立** |
-| 実行 | `03`〜`09`(段取り・点検・投入・出来高・製造条件) | 各作業指示の **製造完了承認**(pm001) |
-| 検査 | `10_inspections.csv`(3件。判定まで) | 各検査の **検査承認** |
+| マスタ | `samples/master-csv` の20ファイル(作業区〜ユーザーのスキル、保全手順書) | ― |
+| 受入 | `01_receiving.csv`(部材10ロット・保全部品3ロット。期限切れ・期限間近のロットを含む) | 在庫照会の **期限アラート** で期限切れ・期限間近のロットを確かめる |
+| 製造指図 | `02_manufacturing-orders.csv`(SMP-SF-001・SMP-FG-001 は展開済み、SMP-FG-002 は承認済み、SMP-FG-003 は未承認)、`17_manufacturing-orders.csv`(リワーク指図 SMP-RW-001。承認済み) | SMP-FG-002・SMP-RW-001 の **工程展開** と **差立** |
+| 実行 | `03`〜`09`(段取り・点検・投入・出来高・製造条件)、`16_equipment-logs.csv`(設備の稼働・故障・停止) | 各作業指示の **製造完了承認**(pm001) |
+| 検査 | `10_inspections.csv`(4件。判定まで。追加入荷の受入検査は不合格) | 各検査の **検査承認** |
| 出荷 | `13`〜`15`(出荷指示2件・出荷判定・出荷実行。SMP-SH-001 は出荷まで完了、SMP-SH-002 は判定が保留のまま) | SMP-SH-002 の出荷判定を「可」にして承認し、出荷実行してみる |
+| 保全 | `18`〜`20`(保全計画3件、突発依頼2件と治工具メンテの実績。消耗品を在庫から引き落とす)。計画から発行した SMP-MT-003 は指示のまま | SMP-MT-003 の **保全実績**、残りの計画からの **指示発行**、**消耗材モニタリング** で消費を確かめる |
+| 治工具・校正 | `21`〜`23`(TL-01 は寿命の80%を超えて警告、TL-03 は払い出したまま使用中、期限切れだった DV-03 を校正) | 治工具管理の **寿命の警告**、TL-03 の **返却** |
+| 在庫・物流 | `24`〜`28`(分割・移動・調整・状態変更・廃棄・返品・払出戻し、搬送2件、ピッキング2件、棚卸2件。SMP-ST-001 は確定まで) | 指示のまま残した搬送・出荷ピッキングの **実行**、SMP-ST-002 の **実棚入力と確定**、**倉庫業務進捗** |
+| 不適合・サンプル | `29`・`30`(受入検査の不合格で起票された不適合を廃棄→承認まで、特採を指示した SMP-NC-001、保管サンプル2件) | SMP-NC-001 の **特採承認**、保管サンプルの **保管期限** の確認 |
| 追跡 | ― | トレーサビリティで `FG1000-S001` や `R3001-260901` を追跡 |
diff --git a/samples/actual-csv/01_receiving.csv b/samples/actual-csv/01_receiving.csv
index 7dfa8ed..29462d3 100644
--- a/samples/actual-csv/01_receiving.csv
+++ b/samples/actual-csv/01_receiving.csv
@@ -7,3 +7,8 @@ RM-3007,40,WH-M02,R3007-260901,2028-08-31,代替Oリング(耐油)
RM-3005,500,WH-M02,R3005-260901,,
RM-3006,20,WH-M02,R3006-260901,2027-02-28,
MP-9002,10,WH-S01,,,ロット番号は自動採番(保全用クーラントフィルタ)
+MP-9002,4,WH-S01,MP9002-260901,,保全実績(19)で消費するクーラントフィルタ
+MP-9003,2,WH-S01,MP9003-260901,,保全実績(19)で消費するシールヒーター
+RM-3001,30,WH-M01,R3001-260902,,鋳造品 追加入荷(受入検査で不合格になる)
+RM-3004,30,WH-M02,R3004-251001,2026-10-31,前回入荷の残り(期限間近。先入れ先出しで先に引き当たる)
+RM-3006,5,WH-M02,R3006-250801,2026-08-31,期限切れ(投入・出荷できないことの確認用)
diff --git a/samples/actual-csv/10_inspections.csv b/samples/actual-csv/10_inspections.csv
index 1d47e1d..a129a1a 100644
--- a/samples/actual-csv/10_inspections.csv
+++ b/samples/actual-csv/10_inspections.csv
@@ -5,3 +5,4 @@ IPQC-SF,,,,,INS-01,2,49.98,,,DV-01,,,
IPQC-SF,,,,,INS-02,1,,キズなし,Pass,,,,
FQC-FG,FinalProduct,FG1000-S001,,,INS-04,1,20.3,,,DV-02,true,A,完成品検査(グレードA)
FQC-FG,,,,,INS-05,1,,漏れなし,Pass,,,,
+RCV-3001B,Receiving,R3001-260902,,,INS-03,1,,巣あり(3個),Fail,,true,,追加入荷の受入検査(不合格→ロットは不良・不適合を自動起票)
diff --git a/samples/actual-csv/16_equipment-logs.csv b/samples/actual-csv/16_equipment-logs.csv
new file mode 100644
index 0000000..7756582
--- /dev/null
+++ b/samples/actual-csv/16_equipment-logs.csv
@@ -0,0 +1,11 @@
+EquipmentAssetNo,Status,StartedAt,EndedAt,StopCause,OrderNo,Sequence,Note
+EQ-01,Setup,2026-09-01 20:30,2026-09-01 20:50,,SMP-SF-001,10,前段取り
+EQ-01,Idle,2026-09-01 20:50,2026-09-01 21:00,,,,
+EQ-01,Running,2026-09-01 21:00,2026-09-02 00:40,,SMP-SF-001,10,夜勤
+EQ-01,Failure,2026-09-02 00:40,2026-09-02 01:10,主軸ベルトの滑り,,,ベルト張りを調整して復旧
+EQ-01,Running,2026-09-02 01:10,2026-09-02 03:00,,SMP-SF-001,10,
+EQ-02,Setup,2026-09-03 08:00,2026-09-03 08:10,,SMP-FG-001,10,
+EQ-02,Running,2026-09-03 08:30,2026-09-03 11:30,,SMP-FG-001,10,
+EQ-03,Running,2026-09-03 12:30,2026-09-03 13:00,,SMP-FG-001,20,
+EQ-04,Running,2026-09-03 13:30,2026-09-03 14:00,,SMP-FG-001,30,
+EQ-04,Stopped,2026-09-03 14:00,2026-09-03 14:20,シールヒーター温度上昇待ち,,,トラブル報告あり
diff --git a/samples/actual-csv/17_manufacturing-orders.csv b/samples/actual-csv/17_manufacturing-orders.csv
new file mode 100644
index 0000000..ec29ff3
--- /dev/null
+++ b/samples/actual-csv/17_manufacturing-orders.csv
@@ -0,0 +1,2 @@
+OrderNo,ProductCode,Quantity,DueDate,OrderType,SourceOrderNo,Note,Approve,Expand,OutputLotNumber
+SMP-RW-001,FG-1000,1,2026-10-09,Rework,SMP-FG-001,梱包工程のシール不良1台の手直し(展開は画面から試す),true,false,
diff --git a/samples/actual-csv/18_maintenance-plans.csv b/samples/actual-csv/18_maintenance-plans.csv
new file mode 100644
index 0000000..a2fb1f9
--- /dev/null
+++ b/samples/actual-csv/18_maintenance-plans.csv
@@ -0,0 +1,4 @@
+EquipmentAssetNo,Category,PlanYear,ScheduledDate,CycleDays,Note
+EQ-01,Periodic,2026,2026-09-30,30,マシニングセンタの月次点検(19 で指示を発行する)
+EQ-01,Periodic,2026,2026-10-30,30,マシニングセンタの月次点検(翌月分。指示は未発行)
+EQ-04,Periodic,2026,2026-12-15,90,真空包装機のシール部の四半期点検
diff --git a/samples/actual-csv/19_maintenance-orders.csv b/samples/actual-csv/19_maintenance-orders.csv
new file mode 100644
index 0000000..eb2d4b3
--- /dev/null
+++ b/samples/actual-csv/19_maintenance-orders.csv
@@ -0,0 +1,5 @@
+MaintenanceNo,EquipmentAssetNo,ToolCode,RequestType,ProcedureNo,ScheduledDate,Note,FromPlan
+SMP-MT-001,EQ-01,,Spot,MNT-EQ01-01,2026-09-02,夜勤中の故障(主軸ベルトの滑り)の点検
+SMP-MT-002,EQ-04,,Spot,MNT-EQ04-01,2026-09-03,シールヒーター温度上昇待ちの停止の点検
+SMP-MT-003,EQ-01,,Planned,MNT-EQ01-01,2026-09-30,月次の定期点検(18 の計画から発行。実績は未登録のまま),true
+SMP-MT-004,,TL-01,Spot,MNT-TL01-01,2026-09-03,エンドミルの刃先の欠け
diff --git a/samples/actual-csv/20_maintenance-records.csv b/samples/actual-csv/20_maintenance-records.csv
new file mode 100644
index 0000000..e7f612f
--- /dev/null
+++ b/samples/actual-csv/20_maintenance-records.csv
@@ -0,0 +1,4 @@
+MaintenanceNo,StartedAt,EndedAt,Result,PartsUsed,Note,ResetToolLife,PartLotNumber,PartLocationCode,PartQuantity,PartNote
+SMP-MT-001,2026-09-02 08:30,2026-09-02 09:30,主軸ベルトの張りを調整し、試運転で滑りが無いことを確認,主軸ベルト(MP-9001。資産管理部品のため在庫は動かさない)は交換せず,,,MP9002-260901,WH-S01,1,クーラントフィルタの目詰まり
+SMP-MT-002,2026-09-03 15:00,2026-09-03 15:40,シールヒーターを交換し、テスト包装でシール幅を確認,,,,MP9003-260901,WH-S01,1,
+SMP-MT-004,2026-09-03 13:00,2026-09-03 13:30,刃先を再研削し外径を確認,寿命はリセットしない(利用実績 20 で寿命の警告を見るため),,,,,,
diff --git a/samples/actual-csv/21_tool-usages.csv b/samples/actual-csv/21_tool-usages.csv
new file mode 100644
index 0000000..1004552
--- /dev/null
+++ b/samples/actual-csv/21_tool-usages.csv
@@ -0,0 +1,5 @@
+ToolCode,OrderNo,Sequence,UsageCount,UsageHours,RecordedAt
+TL-01,SMP-SF-001,10,2400,,2026-09-01 23:30
+TL-01,SMP-SF-001,10,1800,,2026-09-02 04:30
+TL-02,SMP-FG-001,10,,6.5,2026-09-02 16:00
+TL-03,SMP-FG-001,20,150,,2026-09-03 11:00
diff --git a/samples/actual-csv/22_tool-issues.csv b/samples/actual-csv/22_tool-issues.csv
new file mode 100644
index 0000000..7f77776
--- /dev/null
+++ b/samples/actual-csv/22_tool-issues.csv
@@ -0,0 +1,3 @@
+ToolCode,OrderNo,Sequence,Issue,Return,Note
+TL-02,SMP-FG-001,10,true,true,組立治具を払い出して返却
+TL-03,SMP-FG-001,20,true,false,返却待ち(治工具は使用中のまま残る)
diff --git a/samples/actual-csv/23_calibrations.csv b/samples/actual-csv/23_calibrations.csv
new file mode 100644
index 0000000..60d833a
--- /dev/null
+++ b/samples/actual-csv/23_calibrations.csv
@@ -0,0 +1,2 @@
+DeviceCode,CalibratedOn,NextDueOn,Result
+DV-03,2026-09-02,,合格(外部校正機関)
diff --git a/samples/actual-csv/24_inventory-operations.csv b/samples/actual-csv/24_inventory-operations.csv
new file mode 100644
index 0000000..417f4e2
--- /dev/null
+++ b/samples/actual-csv/24_inventory-operations.csv
@@ -0,0 +1,9 @@
+Operation,LotNumber,LocationCode,ToLocationCode,Quantity,NewLotNumber,TargetLotNumber,ProductCode,Status,OrderNo,Sequence,Reason
+Split,R3005-260901,WH-M02,,100,R3005-260901-L,,,,,,組立ラインへ出す分を分ける
+Move,R3005-260901-L,WH-M02,WIP-02,100,,,,,,,組立ラインの定数補充
+Adjust,R3007-260901,WH-M02,,37,,,,,,,実数確認で1個不足(落下品の廃棄漏れ)
+Status,R3003-260901,,,,,,,OnHold,,,外観の抜き取り確認待ち
+Status,R3003-260901,,,,,,,Normal,,,抜き取り確認で問題なし
+Discard,R3006-260901,WH-M02,,0.5,,,,,,,計量時のこぼれ
+Return,R3002-260901,WH-M01,,2,,,,,,,外観不良のためサプライヤーへ返品
+IssueReturn,R3001-260901,WH-M01,,1,,,,,SMP-SF-001,10,投入の数え違いによる戻し
diff --git a/samples/actual-csv/25_transfer-orders.csv b/samples/actual-csv/25_transfer-orders.csv
new file mode 100644
index 0000000..8228cc4
--- /dev/null
+++ b/samples/actual-csv/25_transfer-orders.csv
@@ -0,0 +1,3 @@
+LotNumber,FromLocationCode,ToLocationCode,Quantity,Execute
+SF2000-S001,WIP-02,WH-P01,5,true
+R3004-260901,WH-M02,WIP-02,20,false
diff --git a/samples/actual-csv/26_picking-orders.csv b/samples/actual-csv/26_picking-orders.csv
new file mode 100644
index 0000000..7d37640
--- /dev/null
+++ b/samples/actual-csv/26_picking-orders.csv
@@ -0,0 +1,4 @@
+PickingNo,Type,OrderNo,Sequence,ShippingNo,ProductCode,Quantity,Execute
+SMP-PK-001,ProcessIssue,SMP-FG-001,10,,RM-3006,2,true
+SMP-PK-001,,,,,RM-3005,10,
+SMP-PK-002,Shipping,,,SMP-SH-002,FG-1000,3,false
diff --git a/samples/actual-csv/27_stocktakes.csv b/samples/actual-csv/27_stocktakes.csv
new file mode 100644
index 0000000..7d43295
--- /dev/null
+++ b/samples/actual-csv/27_stocktakes.csv
@@ -0,0 +1,3 @@
+StocktakeNo,LocationCode
+SMP-ST-001,WH-S01
+SMP-ST-002,WH-M01
diff --git a/samples/actual-csv/28_stocktake-counts.csv b/samples/actual-csv/28_stocktake-counts.csv
new file mode 100644
index 0000000..d1a58fe
--- /dev/null
+++ b/samples/actual-csv/28_stocktake-counts.csv
@@ -0,0 +1,3 @@
+StocktakeNo,LotNumber,LocationCode,CountedQuantity,Finalize
+SMP-ST-001,MP9002-260901,WH-S01,2,true
+SMP-ST-001,MP9003-260901,WH-S01,1,
diff --git a/samples/actual-csv/29_nonconformances.csv b/samples/actual-csv/29_nonconformances.csv
new file mode 100644
index 0000000..ad39017
--- /dev/null
+++ b/samples/actual-csv/29_nonconformances.csv
@@ -0,0 +1,3 @@
+ReportNo,LotNumber,Source,Content,OrderNo,Sequence,CauseCategory,CauseDetail,Action,ActionInstruction,ActionRecord,Approve
+,R3001-260902,,,,,,,Discard,サプライヤーへ返送のうえ廃棄する,返品伝票 RT-0001 で返送した,true
+SMP-NC-001,SF2000-S001,Production,組立前の外観確認で内径の擦り傷を2個発見,SMP-FG-001,10,加工,工程10の治具の当たり,SpecialAcceptance,機能に影響が無いため特採とする,,false
diff --git a/samples/actual-csv/30_sample-storages.csv b/samples/actual-csv/30_sample-storages.csv
new file mode 100644
index 0000000..7ab034d
--- /dev/null
+++ b/samples/actual-csv/30_sample-storages.csv
@@ -0,0 +1,3 @@
+SampleNo,LotNumber,Quantity,StorageLocationCode,RetainUntil,Note,Close
+SMP-SP-001,FG1000-S001,1,WH-S01,2027-09-30,完成品の保管サンプル,
+SMP-SP-002,R3004-260901,2,WH-S01,2026-09-30,Oリングの受入保管サンプル(再試験で使用),Consumed
diff --git a/samples/actual-csv/README.md b/samples/actual-csv/README.md
index 62e3bde..52e59ea 100644
--- a/samples/actual-csv/README.md
+++ b/samples/actual-csv/README.md
@@ -3,9 +3,19 @@
[`samples/master-csv/`](../master-csv/) のマスタを取り込んだ後に、**ファイル名の番号順**に取り込むサンプル実績です。
実績はマスタのコード(品目コード・ロケーションコードなど)で参照します。
+全体の流れは次の4つです。マスタのZIP→このフォルダのZIPの順に1回ずつ取り込むだけで、各画面に記録が出ます。
+
+1. **製造から出荷まで**(01〜17):受入 → 製造指図 → 段取り・投入・出来高 → 検査 → 出荷。設備稼働とリワーク指図も
+2. **保全と治工具・校正**(18〜23):保全計画と、16 の故障・停止に対する突発依頼と保全実績、治工具の利用実績と引当、検査機の校正
+3. **在庫と物流**(24〜28):在庫オペレーション、搬送、ピッキング(期限切れロットを避ける先入れ先出し)、棚卸
+4. **品質のフォロー**(29〜30):10 の受入検査で起票された不適合の処置、保管サンプル
+
+画面から操作して試すこと(保全実績の登録、指示のまま残した搬送・ピッキング・棚卸、特採の承認など)は、
+ユーザーガイドの「サンプルで見る 製造から出荷まで」10章にまとめてあります。
+
| # | ファイル | 種別 | 内容 |
|---|---|---|---|
-| 01 | `01_receiving.csv` | 受入 | 部材・代替部材・保全部品の受入。ロット番号の指定/自動採番、有効期限あり/なしの書き分け |
+| 01 | `01_receiving.csv` | 受入 | 部材・代替部材・保全部品の受入。ロット番号の指定/自動採番、有効期限あり/なしの書き分け。期限切れ(R3006-250801)・期限間近(R3004-251001)のロットと、受入検査で不合格にする追加入荷(R3001-260902)。保全実績で消費する保全部品(MP9002-260901・MP9003-260901) |
| 02 | `02_manufacturing-orders.csv` | 製造指図 | 登録→承認→工程展開まで一度に進める行と、承認まで・未承認のままの行 |
| 03 | `03_setup-records.csv` | 段取り実績 | 前段取り・後段取り、異常内容の記録 |
| 04 | `04_checklist-records.csv` | チェックリスト実施 | 同じ指図・工程・チェックリストの行が1回の実施になる。任意項目の未チェック |
@@ -14,18 +24,33 @@
| 07 | `07_consumptions.csv` | 部材投入 | ギアポンプ(FG-1000)の投入。06 で入庫した半製品ロットと、理由付きの代替部品 |
| 08 | `08_production-records.csv` | 生産実績 | FG-1000 の実績。最終工程で製品倉庫へ入庫 |
| 09 | `09_data-records.csv` | 製造条件データ | 工程管理項目との照合(送り速度 1350 は許容範囲外で逸脱になる)と自由記述 |
-| 10 | `10_inspections.csv` | 検査 | 受入・工程内・完成品検査。`InspectionKey` が同じ行が1件の検査。測定値の自動判定と、定性項目の判定 |
+| 10 | `10_inspections.csv` | 検査 | 受入・工程内・完成品検査。`InspectionKey` が同じ行が1件の検査。測定値の自動判定と、定性項目の判定。R3001-260902 は不合格(ロットは不良になり、不適合が自動で起票される) |
| 11 | `11_work-time-records.csv` | 作業時間 | 直接作業(作業指示あり)と間接作業 |
| 12 | `12_trouble-reports.csv` | 製造トラブル報告 | 作業指示・設備に紐づく報告と、紐づかない安全の報告 |
| 13 | `13_shipping-orders.csv` | 出荷指示 | サンプル商事株式会社へ FG-1000 ×5(SMP-SH-001)と、別の出荷先へ ×3(SMP-SH-002) |
| 14 | `14_shipment-judgments.csv` | 出荷判定 | SMP-SH-001 は「可」で承認まで。SMP-SH-002 は「保留」のまま(出荷できないことを確かめる用) |
| 15 | `15_shipments.csv` | 出荷実行 | SMP-SH-001 を FG1000-S001 から5台出荷して完了にする |
+| 16 | `16_equipment-logs.csv` | 設備稼働記録 | 03〜08 と同じ時間帯の稼働・段取り・待機。EQ-01 の夜勤中の故障(主軸ベルト)と、EQ-04 のシールヒーター待ちの停止 |
+| 17 | `17_manufacturing-orders.csv` | 製造指図 | 08 で再作業待ちになった1台を手直しするリワーク指図(SMP-RW-001。元指図 SMP-FG-001、承認まで) |
+| 18 | `18_maintenance-plans.csv` | 保全計画 | 設備ごとの定期保全の計画3件。EQ-01 の9月分は 19 で指示を発行し、残りは計画のまま |
+| 19 | `19_maintenance-orders.csv` | 保全指示・突発依頼 | 16 の故障・停止に対する突発依頼(SMP-MT-001・002)、治工具 TL-01 のメンテナンス(SMP-MT-004)と、18 の保全計画から発行して実績を入れない計画保全(SMP-MT-003) |
+| 20 | `20_maintenance-records.csv` | 保全実績 | SMP-MT-001・002 で消耗品(クーラントフィルタ・シールヒーター)を在庫から引き落とす。SMP-MT-004 は寿命をリセットしない(21 の寿命の警告を見るため)。登録で指示は完了になる |
+| 21 | `21_tool-usages.csv` | 治工具の利用実績 | TL-01 の使用回数が寿命閾値(5000回)の84%になり、寿命の警告に出る。作業指示に紐づけた記録と、使用時間の記録 |
+| 22 | `22_tool-issues.csv` | 治工具の引当・払出 | TL-02 は払出から返却まで。TL-03 は払い出したまま(使用中)で、ほかの作業指示には引き当てられない |
+| 23 | `23_calibrations.csv` | 検査機の校正 | 校正期限切れの DV-03 に合格の校正を記録する。次回期限は校正周期(365日)から決まり、以後の検査に使えるようになる |
+| 24 | `24_inventory-operations.csv` | 在庫オペレーション | 分割(新ロット R3005-260901-L)→ 同じファイルの次の行でそのロットを組立ラインへ移動、理由付きの数量調整、保留→正常の状態変更、廃棄、返品、作業指示からの払出戻し。`Operation` 列で操作を選ぶ |
+| 25 | `25_transfer-orders.csv` | 搬送指示 | 半製品 SF2000-S001 を製品倉庫へ搬送して完了。もう1件は指示のまま残る(倉庫業務進捗に未完了として出る) |
+| 26 | `26_picking-orders.csv` | ピッキング指示 | SMP-PK-001 は工程払出で、RM-3006 は期限切れの R3006-250801 を避けて先入れ先出しで引き当て、払出まで進める。SMP-PK-002 は保留中の出荷指示 SMP-SH-002 の出荷ピッキングで、指示のまま残る |
+| 27 | `27_stocktakes.csv` | 棚卸指示 | 保全部品棚(WH-S01)と部材倉庫(WH-M01)の棚卸。作成時点の在庫が明細になる |
+| 28 | `28_stocktake-counts.csv` | 実棚数 | SMP-ST-001 の実棚を入れて確定する(クーラントフィルタが1個足りず、差異が棚卸調整として在庫に反映される)。SMP-ST-002 は実棚未入力のまま |
+| 29 | `29_nonconformances.csv` | 不適合 | 10 の受入検査の不合格で自動起票された不適合を**ロット番号 R3001-260902 で指して**、廃棄の対応指示→対応記録→承認まで進める。SMP-NC-001 は新規に起票して特採を指示し、承認待ちのまま残す |
+| 30 | `30_sample-storages.csv` | サンプル品保管 | 完成品ロットから保管サンプルを採取する(在庫から抜かれる)。SMP-SP-002 は採取して払出まで |
## 取り込み方
画面上部の **ZIPで一括取込** に、このフォルダのCSVをまとめたZIPを渡せば1回で取り込めます。個別に取り込むときは、メニューの **管理 → 実績CSV取込** の種別ごとのパネルから取り込みます(受入は在庫・物流、製造指図は生産管理、
-段取り〜製造条件データと作業時間は現場記録、検査は品質管理、出荷判定は品質保証、出荷指示・出荷実行は在庫・物流の権限が必要。トラブル報告は誰でも取り込める)。種別はファイル名の `_` より後ろです
-(`05_consumptions.csv` と `07_consumptions.csv` はどちらも `consumptions`)。
+段取り〜製造条件データと作業時間は現場記録、検査は品質管理、出荷判定は品質保証、出荷指示・出荷実行は在庫・物流の権限が必要。トラブル報告・保全指示(突発依頼)・保全実績・治工具の利用実績は誰でも取り込めるが、保全計画と計画保全の指示は保全担当者とシステム管理者だけ。治工具の引当は生産管理、検査機の校正は品質管理、在庫オペレーション・搬送・ピッキング・棚卸は在庫・物流、サンプル品保管は品質管理。不適合の起票と対応記録は誰でもできるが、対応指示と承認は品質管理)。種別はファイル名の `_` より後ろです
+(`05_consumptions.csv` と `07_consumptions.csv` はどちらも `consumptions`、`02` と `17` はどちらも `manufacturing-orders`)。
APIへ直接送ることもできます。
@@ -36,6 +61,17 @@ GET /api/actuals/csv/kinds … 対応種別と列の説明
GET /api/actuals/csv/receiving/template … ヘッダーだけのCSV
```
+## 日付と期限について
+
+サンプルの作業時刻は **2026-09-01〜09-03** に固定しています(受入日・製造日・採番の日付だけは取り込んだ日)。
+
+- ダッシュボード・品質分析・生産性などの期間指定の画面は「今日」を基準に開くので、何も出ないときは
+ **基準日を 2026-09-03 に変えるか「◀ 前へ」で戻して**見てください
+- 期限切れのロット(R3006-250801、期限 2026-08-31)は取り込んだ日によらず期限アラートに出ます。
+ 期限間近のロット(R3004-251001、期限 2026-10-31)が「間近」に見えるのは 2026年10月中だけで、それ以降に取り込むと期限切れとして出ます
+- 資格の期限(`samples/master-csv/19_user-skills.csv`)も同じく固定です。op002 の SK-03 は期限切れ、ほかは 2027年以降に切れます
+- 治工具の寿命は、利用実績(21)の記録日時で数えます。メンテナンスで寿命をリセットすると、リセットした時刻(取り込んだ時刻)より前の記録は数えなくなります
+
## きまり
- **取込は常に新規登録**です。同じファイルを2回取り込むと2回分の実績になります
@@ -50,13 +86,20 @@ GET /api/actuals/csv/receiving/template … ヘッダーだけのCSV
後で投入するロットは受入CSVで番号を指定してください。
- 日時は `yyyy-MM-dd HH:mm`(工場の時刻)で書きます。`+09:00` のようなオフセット付きでも構いません。
生産実績は**開始日時で直を決める**ので、夜勤の実績は夜勤の時間帯に入る時刻にしてください。
-- 実行記録・検査・作業時間・トラブル報告の**記録者は取り込んだユーザー**になります。
+- 実行記録・検査・作業時間・トラブル報告・保全実績の**記録者は取り込んだユーザー**になります(保全手順書に必要スキルがあれば、取り込んだユーザーと照合します)。
CSVで別の作業者の名前を書く列はありません(他人の名義で実績を作れないようにするため)。
+- **ピッキング指示・棚卸指示は番号を必須の手入力にします**(`PK`・`ST` で始まる番号は自動採番用のため指定できません)。実棚数CSVは棚卸番号と、明細のロット番号+ロケーションで指します。搬送指示には番号が無いので、1行の `Execute` で実行まで進めます。
- **出荷指示は「出荷番号」で指します**。出荷判定・出荷実行のCSVから指せるよう、出荷指示CSVの `ShippingNo` は必須です
(`SH` で始まる番号は自動採番用のため指定できません)。出荷番号が同じ行は1件の指示・1回の出荷にまとまります。
- 出荷実行には、その出荷指示を対象にした**承認済みの出荷判定(可・特採)**が要ります。判定の無い指示・保留の指示を
出荷実行CSVに書くと、そのファイルは取り込めません。
-- `DV-03`(校正期限切れ)を `DeviceCode` に書くと、その検査は取り込めません。
+- **保全計画には番号が無い**ので、保全指示CSVの `FromPlan=true` の行は**設備と予定日が一致する未指示の計画が1件だけのとき**にその計画から作ります(同じ設備の計画は予定日を分けてください)。
+- **保全実績は保全指示を「保全指示番号」で指します**。保全指示CSVの `MaintenanceNo` は必須です(`MT` で始まる番号は自動採番用のため指定できません)。
+ 番号が同じ行は1件の実績にまとまり、1行に消費部材を1つ書けます。設備の**資産管理部品**(EQ-01 の主軸ベルト MP-9001 など)は在庫引落しの対象外なので、`PartLotNumber` に書くとそのファイルは取り込めません。
+- **不適合は「不適合番号」か「ロット番号」で指します**。未登録の `ReportNo` の行は新規起票です(`NC` で始まる番号は自動採番用のため指定できません)。
+ 検査の不合格で自動起票された不適合は番号が分からないので、`ReportNo` を空欄にして `LotNumber` で指します。そのロットに未完了の不適合が
+ 2件以上あるとどれか決まらないので、その行は取り込めません(`ReportNo` で指してください)。
+- `DV-03`(校正期限切れ)は 23 の校正を取り込むまで検査に使えません。23 より前のファイルで `DeviceCode` に書くと、その検査は取り込めません。
- 部材はバックフラッシュ(`Backflush=true`)でも消費できます。引くのは**その工程で使う部材だけ**で、
サンプルのMBOMでは部材をすべて最初の工程(`Sequence=10`)で使います。部材を使わない工程(20・30)の行を
`Backflush=true` にすると、そのファイルは取り込めません。また代替部品は消費されない(主材料だけを引く)ので、
diff --git a/samples/master-csv/19_user-skills.csv b/samples/master-csv/19_user-skills.csv
index 3342adc..8479e75 100644
--- a/samples/master-csv/19_user-skills.csv
+++ b/samples/master-csv/19_user-skills.csv
@@ -5,3 +5,4 @@ op002,SK-04,2024-04-01,
op003,SK-01,2025-10-01,
qc001,SK-03,2024-10-01,2027-09-30
lg001,SK-02,2022-11-20,2027-11-19
+op002,SK-03,2025-04-01,2026-03-31
diff --git a/samples/master-csv/20_maintenance-procedures.csv b/samples/master-csv/20_maintenance-procedures.csv
new file mode 100644
index 0000000..01fa517
--- /dev/null
+++ b/samples/master-csv/20_maintenance-procedures.csv
@@ -0,0 +1,12 @@
+ProcedureNo,Title,TargetEquipmentAssetNo,TargetToolCode,RequiredSkillCode,Steps,IsActive
+MNT-EQ01-01,マシニングセンタ 定期点検,EQ-01,,,"1. 主軸の振れを測定する
+2. 切削油の濃度と量を確認する
+3. 主軸ベルトの張りと摩耗を点検する",true
+MNT-EQ05-01,マシニングセンタ(2号機) 主軸ベルト交換,EQ-05,,SK-01,"1. 主電源を切り、ロックアウトする
+2. 主軸ベルト(MP-9001)を交換する
+3. 試運転で異音・振動が無いことを確認する",true
+MNT-EQ04-01,真空包装機 シール部点検,EQ-04,,,"1. ヒーター線(MP-9003)の断線・焼けを確認する
+2. テスト包装でシール幅を確認する",true
+MNT-TL01-01,エンドミル 再研削,,TL-01,,"1. 刃先の摩耗を拡大鏡で確認する
+2. 外注の再研削に出す
+3. 戻り品の外径を測定する",true
diff --git a/samples/master-csv/README.md b/samples/master-csv/README.md
index a4b2a4d..945de5e 100644
--- a/samples/master-csv/README.md
+++ b/samples/master-csv/README.md
@@ -42,9 +42,10 @@ RM-3001 ハウジング鋳造品
| 16 | `16_work-procedures.csv` | 作業手順書 | 手順を本文に書く例と、所在だけを書く例(システム管理者のみ取込可) |
| 17 | `17_routing.csv` | 品目・MBOM・工順 | 候補設備・作業区・治工具・スキル・チェックリスト・工程管理項目・手順書を紐づけた工順 |
| 18 | `18_users.csv` | ユーザー | 各ロールの担当者。作業場所・所属・直つき(システム管理者のみ取込可) |
-| 19 | `19_user-skills.csv` | ユーザー | ユーザーへのスキル・資格の割当(システム管理者のみ取込可) |
+| 19 | `19_user-skills.csv` | ユーザー | ユーザーへのスキル・資格の割当。期限切れの資格を1件含む(システム管理者のみ取込可) |
+| 20 | `20_maintenance-procedures.csv` | 保全(保全手順書タブ) | 設備用・治工具用の手順書。`MNT-EQ05-01` は必要スキル(SK-01)つきで、保全実績の登録時に実施者と照合される(システム管理者と保全担当のみ取込可) |
-取り込み方は、マスタ管理の各タブで **CSV入出力 → 取込ファイルを選択 → 取込を実行** です。
+取り込み方は、マスタ管理の各タブ(保全手順書は保全管理の保全手順書タブ)で **CSV入出力 → 取込ファイルを選択 → 取込を実行** です。
このフォルダのCSVをまとめてZIPにすれば、画面上部の **ZIPで一括取込** から1回で取り込めます(ファイル名の順。1ファイルでもエラーがあればZIPの全ファイルを取り消し)。
初めての場合は **検証のみ** にチェックを入れて実行し、エラーが出ないことを確認してから
@@ -71,6 +72,7 @@ RM-3001 ハウジング鋳造品
- `14_inspection-devices.csv` の `DV-03` は校正期限切れです。検査実績の測定器に選ぶと登録できません
- `SF-2000` の機械加工は `EQ-01` と `EQ-05` のどちらでも作業できます(候補設備)
- `op003` は夜勤(20:00〜翌05:00)の作業者です。直別の集計を試せます
+- `op002` の `SK-03`(社内検査員認定)は 2026-03-31 で期限切れです。SK-03 が必要な最終検査(FG-1000 の工程20)に差立・着手しようとすると拒否されます
## 注意
diff --git a/src/MesApp.Api/Controllers/InspectionDevicesController.cs b/src/MesApp.Api/Controllers/InspectionDevicesController.cs
index 53e4b0f..e6f8955 100644
--- a/src/MesApp.Api/Controllers/InspectionDevicesController.cs
+++ b/src/MesApp.Api/Controllers/InspectionDevicesController.cs
@@ -1,4 +1,5 @@
using MesApp.Api.Localization;
+using MesApp.Api.Services;
using System.Security.Claims;
using MesApp.Api.Policies;
using MesApp.Core.Abstractions;
@@ -21,7 +22,8 @@ namespace MesApp.Api.Controllers;
[Route("api/inspection-devices")]
[Authorize]
public class InspectionDevicesController(
- MesAppDbContext db, IAuditLogger auditLogger, IBusinessDateService businessDate) : ControllerBase
+ MesAppDbContext db, IAuditLogger auditLogger, IBusinessDateService businessDate,
+ InspectionDeviceService devices) : ControllerBase
{
[HttpGet]
public async Task
>> List(
@@ -127,38 +129,13 @@ await auditLogger.LogAsync("Master", "Update", nameof(InspectionDevice), id.ToSt
public async Task> AddCalibration(
int id, InspectionDeviceCalibrationRequest request, CancellationToken ct)
{
- var device = await db.InspectionDevices.FindAsync([id], ct);
- if (device is null)
+ // 判定・保存は実績CSV取込と共通(InspectionDeviceService)
+ var outcome = await devices.AddCalibrationAsync(id, request, User.FindFirstValue(ClaimTypes.NameIdentifier), ct);
+ if (outcome.Failed)
{
return NotFound();
}
-
- var nextDue = request.NextDueOn
- ?? (device.CalibrationCycleDays is { } cycle
- ? request.CalibratedOn.AddDays(cycle)
- : null);
- var calibration = new InspectionDeviceCalibration
- {
- InspectionDeviceId = id,
- CalibratedOn = request.CalibratedOn,
- NextDueOn = nextDue,
- Result = request.Result,
- PerformedByUserId = User.FindFirstValue(ClaimTypes.NameIdentifier),
- };
- db.InspectionDeviceCalibrations.Add(calibration);
-
- var before = new { device.CalibratedOn, device.CalibrationDueOn };
- device.CalibratedOn = request.CalibratedOn;
- device.CalibrationDueOn = nextDue;
- await db.SaveChangesAsync(ct);
-
- await auditLogger.LogAsync("Master", "Calibrate", nameof(InspectionDevice), id.ToString(),
- detail: new
- {
- before,
- after = new { device.CalibratedOn, device.CalibrationDueOn },
- reason = request.Result,
- }, ct: ct);
+ var calibration = outcome.Value!;
return new InspectionDeviceCalibrationResponse(
calibration.Id, id, calibration.CalibratedOn, calibration.NextDueOn,
diff --git a/src/MesApp.Api/Controllers/MaintenanceOrdersController.cs b/src/MesApp.Api/Controllers/MaintenanceOrdersController.cs
index cb4c315..0864e2b 100644
--- a/src/MesApp.Api/Controllers/MaintenanceOrdersController.cs
+++ b/src/MesApp.Api/Controllers/MaintenanceOrdersController.cs
@@ -73,12 +73,11 @@ public async Task> Create(
{
// 計画保全の作成は保全ロールのみ。突発依頼(Spot)は現場からも起票できる
if (request.RequestType == MaintenanceRequestType.Planned
- && !User.IsInRole(Core.Constants.MesRoles.SystemAdmin)
- && !User.IsInRole(Core.Constants.MesRoles.Maintenance))
+ && !Core.Constants.MesRoleGroups.IsInGroup(User, MaintenanceOrderService.PlannedOrderRoles))
{
return Forbid();
}
- var outcome = await maintenanceOrders.CreateAsync(request, CurrentUserId, ct);
+ var outcome = await maintenanceOrders.CreateAsync(request, null, CurrentUserId, ct);
if (outcome.Failed)
{
return ToProblem(outcome);
diff --git a/src/MesApp.Api/Controllers/MaintenancePlansController.cs b/src/MesApp.Api/Controllers/MaintenancePlansController.cs
index 854de18..88c15ad 100644
--- a/src/MesApp.Api/Controllers/MaintenancePlansController.cs
+++ b/src/MesApp.Api/Controllers/MaintenancePlansController.cs
@@ -60,27 +60,15 @@ public async Task> Get(int id, Cancellatio
public async Task> Create(
MaintenancePlanRequest request, CancellationToken ct)
{
- var equipment = await db.Equipments.FirstOrDefaultAsync(e => e.Id == request.EquipmentId, ct);
- if (equipment is null || !equipment.IsActive)
+ // 判定・保存は実績CSV取込と共通(MaintenanceOrderService)
+ var outcome = await maintenanceOrders.CreatePlanAsync(
+ request, User.FindFirstValue(ClaimTypes.NameIdentifier), ct);
+ if (outcome.Failed)
{
- return this.BadRequestProblem(ApiText.T("存在しない(または無効な)設備IDです。"));
+ return this.BadRequestProblem(outcome.Error!);
}
-
- var plan = new MaintenancePlan
- {
- EquipmentId = request.EquipmentId,
- Category = request.Category,
- PlanYear = request.PlanYear,
- ScheduledDate = request.ScheduledDate,
- CycleDays = request.CycleDays,
- Note = request.Note,
- CreatedByUserId = User.FindFirstValue(ClaimTypes.NameIdentifier),
- };
- db.MaintenancePlans.Add(plan);
- await db.SaveChangesAsync(ct);
- await auditLogger.LogAsync("Maintenance", "PlanCreate", nameof(MaintenancePlan), plan.Id.ToString(),
- detail: $"equipment={equipment.AssetNo}, year={plan.PlanYear}", ct: ct);
- return CreatedAtAction(nameof(Get), new { id = plan.Id }, await GetResponseAsync(plan.Id, ct));
+ var id = outcome.Value!.Id;
+ return CreatedAtAction(nameof(Get), new { id }, await GetResponseAsync(id, ct));
}
/// 計画の変更(E-30-10-03。指示発行前のみ)
diff --git a/src/MesApp.Api/Controllers/NonconformanceController.cs b/src/MesApp.Api/Controllers/NonconformanceController.cs
index 6a3346f..c8dcde0 100644
--- a/src/MesApp.Api/Controllers/NonconformanceController.cs
+++ b/src/MesApp.Api/Controllers/NonconformanceController.cs
@@ -61,7 +61,7 @@ public async Task> Get(int id, Cancellation
public async Task> Create(
NonconformanceCreateRequest request, CancellationToken ct)
{
- var outcome = await nonconformances.CreateAsync(request, CurrentUserId, ct);
+ var outcome = await nonconformances.CreateAsync(request, null, CurrentUserId, ct);
if (outcome.Failed)
{
return ToProblem(outcome);
diff --git a/src/MesApp.Api/Controllers/PickingOrdersController.cs b/src/MesApp.Api/Controllers/PickingOrdersController.cs
index 3070e3a..e052f89 100644
--- a/src/MesApp.Api/Controllers/PickingOrdersController.cs
+++ b/src/MesApp.Api/Controllers/PickingOrdersController.cs
@@ -59,7 +59,7 @@ public async Task> Get(int id, CancellationTo
public async Task> Create(
PickingOrderCreateRequest request, CancellationToken ct)
{
- var outcome = await picking.CreateAsync(request, CurrentUserId, ct);
+ var outcome = await picking.CreateAsync(request, null, CurrentUserId, ct);
if (outcome.Failed)
{
return ToProblem(outcome);
diff --git a/src/MesApp.Api/Controllers/SampleStoragesController.cs b/src/MesApp.Api/Controllers/SampleStoragesController.cs
index 959c5bf..564babd 100644
--- a/src/MesApp.Api/Controllers/SampleStoragesController.cs
+++ b/src/MesApp.Api/Controllers/SampleStoragesController.cs
@@ -1,4 +1,3 @@
-using MesApp.Api.Localization;
using System.Linq.Expressions;
using System.Security.Claims;
using MesApp.Api.Services;
@@ -23,10 +22,8 @@ namespace MesApp.Api.Controllers;
[Authorize]
public class SampleStoragesController(
MesAppDbContext db,
- InventoryService inventory,
- NumberingService numbering,
- IBusinessDateService businessDate,
- IAuditLogger auditLogger) : ControllerBase
+ SampleStorageService samples,
+ IBusinessDateService businessDate) : ControllerBase
{
private string? CurrentUserId => User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -67,117 +64,38 @@ public async Task> Get(int id, CancellationT
}
///
- /// サンプルの採取(D-40-50-01)。採取した分は在庫から抜く
- /// (保管棚へ移り、出荷・投入には使えない。在庫に残すと引当・先入れ先出しの対象になってしまう)
+ /// サンプルの採取(D-40-50-01)。採取した分は在庫から抜く(判定・保存は実績CSV取込と共通の SampleStorageService)
///
[HttpPost]
[Authorize(Roles = MesRoleGroups.QualityManage)]
public async Task> Collect(
SampleCollectRequest request, CancellationToken ct)
{
- var lot = await db.Lots.FirstOrDefaultAsync(l => l.Id == request.LotId, ct);
- if (lot is null)
+ var outcome = await samples.CollectAsync(request, null, CurrentUserId, ct);
+ if (outcome.Failed)
{
- return this.NotFoundProblem(ApiText.T("ロットID {0} は登録されていません。", request.LotId));
+ return outcome.Kind == OutcomeError.NotFound
+ ? this.NotFoundProblem(outcome.Error)
+ : this.BadRequestProblem(outcome.Error);
}
- if (!await db.Locations.AnyAsync(l => l.Id == request.StorageLocationId && l.IsActive, ct))
- {
- return this.BadRequestProblem(ApiText.T("保管場所が見つからないか無効です。"));
- }
- if (request.InspectionOrderId is { } inspectionId
- && !await db.InspectionOrders.AnyAsync(i => i.Id == inspectionId, ct))
- {
- return this.BadRequestProblem(ApiText.T("検査指示ID {0} は登録されていません。", inspectionId));
- }
-
- // 採取元の在庫は、サンプルの保管場所ではなく現物があった場所から抜く
- var from = await db.InventoryStocks.AsNoTracking()
- .Where(s => s.LotId == lot.Id && s.Quantity > 0)
- .OrderByDescending(s => s.Quantity)
- .Select(s => (int?)s.LocationId)
- .FirstOrDefaultAsync(ct);
- if (from is null)
- {
- return this.BadRequestProblem(
- ApiText.T("ロット {0} の在庫がありません。", lot.LotNumber));
- }
-
- var sample = new SampleStorage
- {
- SampleNo = await numbering.NextSampleNoAsync(ct),
- ProductId = lot.ProductId,
- LotId = lot.Id,
- InspectionOrderId = request.InspectionOrderId,
- Quantity = request.Quantity,
- StorageLocationId = request.StorageLocationId,
- CollectedOn = businessDate.Today,
- RetainUntil = request.RetainUntil,
- Status = SampleStorageStatus.Stored,
- CollectedByUserId = CurrentUserId,
- Note = request.Note,
- };
- db.SampleStorages.Add(sample);
-
- try
- {
- await inventory.RemoveAsync(lot, from.Value, request.Quantity,
- InventoryTransactionType.SampleRetention, CurrentUserId,
- note: $"サンプル採取 {sample.SampleNo}", ct: ct);
- }
- catch (InventoryException ex)
- {
- return this.BadRequestProblem(ex.Message);
- }
-
- await db.SaveChangesAsync(ct);
- await auditLogger.LogAsync("Inventory", "SampleCollect", nameof(SampleStorage),
- sample.Id.ToString(),
- detail: $"sample={sample.SampleNo} lot={lot.LotNumber} qty={request.Quantity}", ct: ct);
- return CreatedAtAction(nameof(Get), new { id = sample.Id }, await LoadAsync(sample.Id, ct));
+ var id = outcome.Value!.Id;
+ return CreatedAtAction(nameof(Get), new { id }, await LoadAsync(id, ct));
}
- ///
- /// 保管の終了(払出・廃棄)。**在庫は動かさない**(採取時に既に抜いてあるため)。
- /// 保管期限前の処分も禁止しない——再試験のための払出は期限前に起こるのが普通で、
- /// 止めると運用が画面の外へ出てしまう。期限前だったかどうかは監査ログに残す
- ///
+ /// 保管の終了(払出・廃棄)。在庫は動かさない(採取時に既に抜いてあるため)
[HttpPost("{id:int}/close")]
[Authorize(Roles = MesRoleGroups.QualityManage)]
public async Task> Close(
int id, SampleCloseRequest request, CancellationToken ct)
{
- var sample = await db.SampleStorages.FirstOrDefaultAsync(x => x.Id == id, ct);
- if (sample is null)
- {
- return NotFound();
- }
- if (sample.Status != SampleStorageStatus.Stored)
+ var outcome = await samples.CloseAsync(id, request, CurrentUserId, ct);
+ return outcome.Kind switch
{
- return this.ConflictProblem(
- ApiText.T("サンプル {0} は既に保管を終えています。", sample.SampleNo));
- }
- if (request.Status is not (SampleStorageStatus.Consumed or SampleStorageStatus.Disposed))
- {
- return this.BadRequestProblem(ApiText.T("払出済か廃棄済かを指定してください。"));
- }
-
- var before = sample.Status;
- var beforeRetentionEnd = sample.RetainUntil is { } until && businessDate.Today <= until;
- sample.Status = request.Status;
- sample.ClosedOn = businessDate.Today;
- sample.ClosedByUserId = CurrentUserId;
- sample.Note = request.Note ?? sample.Note;
- await db.SaveChangesAsync(ct);
-
- await auditLogger.LogAsync("Inventory", "SampleClose", nameof(SampleStorage), id.ToString(),
- detail: new
- {
- before,
- after = sample.Status,
- reason = request.Note,
- beforeRetentionEnd,
- }, ct: ct);
- return await LoadAsync(id, ct);
+ OutcomeError.None => await LoadAsync(id, ct),
+ OutcomeError.NotFound => NotFound(),
+ OutcomeError.Conflict => this.ConflictProblem(outcome.Error),
+ _ => this.BadRequestProblem(outcome.Error),
+ };
}
private async Task LoadAsync(int id, CancellationToken ct) =>
diff --git a/src/MesApp.Api/Controllers/StocktakesController.cs b/src/MesApp.Api/Controllers/StocktakesController.cs
index 59a0986..676220f 100644
--- a/src/MesApp.Api/Controllers/StocktakesController.cs
+++ b/src/MesApp.Api/Controllers/StocktakesController.cs
@@ -46,7 +46,7 @@ public async Task> Get(int id, CancellationToken
public async Task> Create(
StocktakeCreateRequest request, CancellationToken ct)
{
- var outcome = await stocktakes.CreateAsync(request, CurrentUserId, ct);
+ var outcome = await stocktakes.CreateAsync(request, null, CurrentUserId, ct);
if (outcome.Failed)
{
return ToProblem(outcome);
diff --git a/src/MesApp.Api/Controllers/ToolUsagesController.cs b/src/MesApp.Api/Controllers/ToolUsagesController.cs
index c7114c8..ad098ae 100644
--- a/src/MesApp.Api/Controllers/ToolUsagesController.cs
+++ b/src/MesApp.Api/Controllers/ToolUsagesController.cs
@@ -1,9 +1,8 @@
-using MesApp.Api.Localization;
+using MesApp.Api.Services;
using MesApp.Core.Abstractions;
using System.Security.Claims;
using MesApp.Core.Contracts.Common;
using MesApp.Core.Contracts.Maintenance;
-using MesApp.Core.Entities;
using MesApp.Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -23,7 +22,7 @@ namespace MesApp.Api.Controllers;
[Route("api/tool-usages")]
[Authorize]
public class ToolUsagesController(
- MesAppDbContext db, IAuditLogger auditLogger, IBusinessDateService businessDate) : ControllerBase
+ MesAppDbContext db, ShopFloorReportService reports, IBusinessDateService businessDate) : ControllerBase
{
[HttpGet]
public async Task>> List(
@@ -52,33 +51,14 @@ public async Task>> List(
[HttpPost]
public async Task> Create(ToolUsageRequest request, CancellationToken ct)
{
- var tool = await db.Tools.FirstOrDefaultAsync(t => t.Id == request.ToolId, ct);
- if (tool is null || !tool.IsActive)
+ // 判定・保存は実績CSV取込と共通(ShopFloorReportService)
+ var outcome = await reports.AddToolUsageAsync(
+ request, null, User.FindFirstValue(ClaimTypes.NameIdentifier), ct);
+ if (outcome.Failed)
{
- return this.BadRequestProblem(ApiText.T("存在しない(または無効な)治工具IDです。"));
+ return this.BadRequestProblem(outcome.Error!);
}
- if (request.WorkOrderId is int workOrderId
- && !await db.WorkOrders.AnyAsync(w => w.Id == workOrderId, ct))
- {
- return this.BadRequestProblem(ApiText.T("存在しない作業指示IDです。"));
- }
- if (request.UsageCount <= 0 && (request.UsageHours is null or <= 0))
- {
- return this.BadRequestProblem(ApiText.T("使用回数または使用時間のどちらかを記録してください。"));
- }
-
- var usage = new ToolUsage
- {
- ToolId = request.ToolId,
- WorkOrderId = request.WorkOrderId,
- UsageCount = request.UsageCount,
- UsageHours = request.UsageHours,
- RecordedByUserId = User.FindFirstValue(ClaimTypes.NameIdentifier),
- };
- db.ToolUsages.Add(usage);
- await db.SaveChangesAsync(ct);
- await auditLogger.LogAsync("Equipment", "ToolUsage", nameof(ToolUsage), usage.Id.ToString(),
- detail: new { toolId = usage.ToolId, count = usage.UsageCount, hours = usage.UsageHours }, ct: ct);
+ var usage = outcome.Value!;
return await db.ToolUsages.AsNoTracking()
.Where(u => u.Id == usage.Id)
diff --git a/src/MesApp.Api/Controllers/TransferOrdersController.cs b/src/MesApp.Api/Controllers/TransferOrdersController.cs
index bf2edbf..1d2b532 100644
--- a/src/MesApp.Api/Controllers/TransferOrdersController.cs
+++ b/src/MesApp.Api/Controllers/TransferOrdersController.cs
@@ -1,8 +1,5 @@
-using MesApp.Core.Localization;
-using MesApp.Api.Localization;
using System.Security.Claims;
using MesApp.Api.Services;
-using MesApp.Core.Abstractions;
using MesApp.Core.Contracts.Common;
using MesApp.Core.Contracts.Execution;
using MesApp.Core.Entities;
@@ -26,8 +23,7 @@ namespace MesApp.Api.Controllers;
[Authorize]
public class TransferOrdersController(
MesAppDbContext db,
- InventoryService inventory,
- IAuditLogger auditLogger) : ControllerBase
+ TransferOrderService transfers) : ControllerBase
{
[HttpGet]
public async Task>> List(
@@ -57,34 +53,14 @@ public async Task> Get(int id, CancellationT
[Authorize(Roles = MesRoleGroups.InventoryManage)]
public async Task> Create(TransferOrderRequest request, CancellationToken ct)
{
- if (!await db.Lots.AnyAsync(l => l.Id == request.LotId, ct))
+ // 判定・保存は実績CSV取込と共通(TransferOrderService)
+ var outcome = await transfers.CreateAsync(request, CurrentUserId, ct);
+ if (outcome.Failed)
{
- return this.BadRequestProblem(ApiText.T("存在しないロットIDです。"));
+ return ToProblem(outcome);
}
- if (request.FromLocationId == request.ToLocationId)
- {
- return this.BadRequestProblem(ApiText.T("移動元と移動先が同一です。"));
- }
- var locationIds = new[] { request.FromLocationId, request.ToLocationId };
- if (await db.Locations.CountAsync(l => locationIds.Contains(l.Id) && l.IsActive, ct) != 2)
- {
- return this.BadRequestProblem(ApiText.T("存在しない(または無効な)ロケーションが含まれています。"));
- }
-
- var order = new TransferOrder
- {
- LotId = request.LotId,
- Quantity = request.Quantity,
- FromLocationId = request.FromLocationId,
- ToLocationId = request.ToLocationId,
- CreatedByUserId = User.FindFirstValue(ClaimTypes.NameIdentifier),
- };
- db.TransferOrders.Add(order);
- await db.SaveChangesAsync(ct);
- await auditLogger.LogAsync("Inventory", "TransferCreate", nameof(TransferOrder), order.Id.ToString(),
- detail: new { lotId = order.LotId, quantity = order.Quantity,
- from = order.FromLocationId, to = order.ToLocationId }, ct: ct);
- return CreatedAtAction(nameof(Get), new { id = order.Id }, await GetResponseAsync(order.Id, ct));
+ var id = outcome.Value!.Id;
+ return CreatedAtAction(nameof(Get), new { id }, await GetResponseAsync(id, ct));
}
/// 移動実行(B-50-10-02。在庫を移動して完了にする)
@@ -92,56 +68,27 @@ await auditLogger.LogAsync("Inventory", "TransferCreate", nameof(TransferOrder),
[Authorize(Roles = MesRoleGroups.InventoryManage)]
public async Task> Execute(int id, CancellationToken ct)
{
- var order = await db.TransferOrders.Include(t => t.Lot).FirstOrDefaultAsync(t => t.Id == id, ct);
- if (order is null)
- {
- return NotFound();
- }
- if (order.Status != TransferOrderStatus.Instructed)
- {
- return this.ConflictProblem(ApiText.T("状態 '{0}' の搬送指示は実行できません。", EnumLabels.Of(order.Status)));
- }
-
- try
- {
- await inventory.MoveAsync(order.Lot!, order.FromLocationId, order.ToLocationId, order.Quantity,
- InventoryTransactionType.Move, User.FindFirstValue(ClaimTypes.NameIdentifier),
- note: $"搬送指示 #{order.Id}", ct: ct);
- }
- catch (InventoryException ex)
- {
- return this.BadRequestProblem(ex.Message);
- }
-
- order.Status = TransferOrderStatus.Completed;
- order.ExecutedByUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
- order.ExecutedAt = DateTimeOffset.UtcNow;
- await db.SaveChangesAsync(ct);
- await auditLogger.LogAsync("Inventory", "Transfer", nameof(TransferOrder), id.ToString(), ct: ct);
- return await GetResponseAsync(id, ct);
+ var outcome = await transfers.ExecuteAsync(id, CurrentUserId, ct);
+ return outcome.Failed ? ToProblem(outcome) : await GetResponseAsync(id, ct);
}
[HttpPost("{id:int}/cancel")]
[Authorize(Roles = MesRoleGroups.InventoryManage)]
public async Task> Cancel(int id, CancellationToken ct)
{
- var order = await db.TransferOrders.FindAsync([id], ct);
- if (order is null)
- {
- return NotFound();
- }
- if (order.Status != TransferOrderStatus.Instructed)
- {
- return this.ConflictProblem(ApiText.T("状態 '{0}' の搬送指示は取消できません。", EnumLabels.Of(order.Status)));
- }
- var before = order.Status;
- order.Status = TransferOrderStatus.Canceled;
- await db.SaveChangesAsync(ct);
- await auditLogger.LogAsync("Inventory", "TransferCancel", nameof(TransferOrder), id.ToString(),
- detail: new { before, after = order.Status }, ct: ct);
- return await GetResponseAsync(id, ct);
+ var outcome = await transfers.CancelAsync(id, ct);
+ return outcome.Failed ? ToProblem(outcome) : await GetResponseAsync(id, ct);
}
+ private string? CurrentUserId => User.FindFirstValue(ClaimTypes.NameIdentifier);
+
+ private ActionResult ToProblem(Outcome outcome) => outcome.Kind switch
+ {
+ OutcomeError.NotFound => NotFound(),
+ OutcomeError.Conflict => this.ConflictProblem(outcome.Error),
+ _ => this.BadRequestProblem(outcome.Error),
+ };
+
private async Task GetResponseAsync(int id, CancellationToken ct) =>
await db.TransferOrders.AsNoTracking()
.Where(t => t.Id == id).Select(Projection).FirstAsync(ct);
diff --git a/src/MesApp.Api/Localization/ApiText.en.resx b/src/MesApp.Api/Localization/ApiText.en.resx
index ca8e205..3193d88 100644
--- a/src/MesApp.Api/Localization/ApiText.en.resx
+++ b/src/MesApp.Api/Localization/ApiText.en.resx
@@ -2064,4 +2064,523 @@
A canceled inspection cannot be corrected.
+
+ Picking number '{0}' cannot be specified because it overlaps the auto-numbering format ({1}...).
+
+
+ Picking number '{0}' already exists.
+
+
+ Stocktake number '{0}' cannot be specified because it overlaps the auto-numbering format ({1}...).
+
+
+ Stocktake number '{0}' already exists.
+
+
+ Nonconformance number '{0}' cannot be specified because it overlaps the auto-numbering format ({1}...).
+
+
+ Nonconformance number '{0}' already exists.
+
+
+ Maintenance order number '{0}' cannot be specified because it overlaps the auto-numbering format ({1}...).
+
+
+ Maintenance order number '{0}' already exists.
+
+
+ Maintenance procedures
+
+
+ Target equipment asset number
+
+
+ For an equipment procedure. Leave blank to clear
+
+
+ Target tool code
+
+
+ For a tool maintenance procedure. Leave blank to clear
+
+
+ Checked against the performer when recording maintenance. Leave blank to clear
+
+
+ One lot per row, issued from stock. Asset parts and consumables of the equipment are judged the same way as on the screen
+
+
+ Planned / Spot (unplanned request). Defaults to Spot. Only maintenance staff can register Planned
+
+
+ true resets the tool life counter (tool maintenance orders only)
+
+
+ yyyy-MM-dd HH:mm (factory time). An offset such as +09:00 is also accepted. The value on the first row of the group is used
+
+
+ Lot '{0}' is not registered (PartLotNumber).
+
+
+ Scheduled date
+
+
+ Parts used (notes)
+
+
+ Request type
+
+
+ Maintenance records
+
+
+ Maintenance procedure number
+
+
+ Maintenance orders / unplanned requests
+
+
+ Maintenance order number
+
+
+ Result
+
+
+ Either this or the target tool code is required
+
+
+ Reset tool life
+
+
+ Referenced from the maintenance record CSV that follows. Numbers starting with MT are reserved for automatic numbering
+
+
+ Active maintenance procedure
+
+
+ For a tool maintenance order
+
+
+ Quantity consumed
+
+
+ A consumed part needs PartLocationCode (issuing location) and PartQuantity (quantity).
+
+
+ Consumed part lot number
+
+
+ Required when a consumed part is written
+
+
+ A row with a consumed part needs PartLotNumber (lot number of the part).
+
+
+ A registered, active maintenance procedure
+
+
+ A registered maintenance order (MaintenanceNo in the maintenance order CSV). Rows with the same number form one record, and registering it completes the order
+
+
+ Free text (write parts that move stock in the Part... columns)
+
+
+ Only maintenance staff can register planned maintenance orders (anyone can register unplanned requests).
+
+
+ Part note
+
+
+ Maintenance order '{0}' is not registered (MaintenanceNo).
+
+
+ Inspection device not found.
+
+
+ Integer of 0 or more. Either this or the usage hours must be greater than 0
+
+
+ true continues from allocation to issue and receipt confirmation (the receiver is the importing user)
+
+
+ true continues to return (the tool can be allocated again)
+
+
+ yyyy-MM-dd HH:mm (factory time). An offset such as +09:00 is also accepted. Blank means the import time. Tool life only counts records after the time the life was reset by maintenance
+
+
+ yyyy-MM-dd. Becomes the device's last calibration date
+
+
+ yyyy-MM-dd. Blank means calibration date + calibration cycle (no due date if there is no cycle)
+
+
+ Tools in use, under maintenance or at end of life cannot be allocated
+
+
+ Usage count
+
+
+ Usage hours
+
+
+ Pass, pass after adjustment, etc.
+
+
+ Issue
+
+
+ Calibration date
+
+
+ Calibration result
+
+
+ Inspection device calibrations
+
+
+ Tool usage records
+
+
+ Tool allocation / issue
+
+
+ A registered, active tool
+
+
+ A registered inspection/measuring device
+
+
+ Recorded at
+
+
+ Return
+
+
+ Active tool
+
+
+ Operation '{0}' requires {1}.
+
+
+ Quantity must be greater than 0.
+
+
+ Lot '{0}' is not registered ({1}).
+
+
+ Move / Adjust (quantity adjustment) / Status (status change) / Split / Merge / Transfer / Discard / Return (return to supplier) / IssueReturn (return from issue)
+
+
+ Lot number
+
+
+ Number of the lot created by split or transfer. Blank means automatic numbering (specify it to reference the lot from later rows)
+
+
+ For a product transfer. A transfer needs either the destination product or a new lot number
+
+
+ Inventory operations
+
+
+ Stock status
+
+
+ To record the work order on a return from issue (specify with the routing sequence)
+
+
+ Destination product code
+
+
+ Operation
+
+
+ Lot to operate on (the source lot for a merge). Lots split or transferred on earlier rows can also be specified
+
+
+ Location of the stock (source of a move, destination of a return from issue). Required except for status changes
+
+
+ Quantity
+
+
+ Required for adjustments. For status changes, discards and returns it is kept in the status/stock history
+
+
+ New lot number
+
+
+ Required for status changes. Normal / OnHold / AwaitingInspection / Defective / ToBeDiscarded
+
+
+ Required except for status changes and merges. For adjustments, the quantity after adjustment (0 or more); otherwise a number greater than 0
+
+
+ Reason
+
+
+ Required for moves
+
+
+ Destination location code
+
+
+ Required for merges. A lot of the same product at the same location
+
+
+ Merge target lot number
+
+
+ Quantity is required.
+
+
+ Shipping picking requires ShippingNo (shipping number).
+
+
+ Stocktake '{0}' is not registered (StocktakeNo).
+
+
+ CountedQuantity (counted quantity) is required.
+
+
+ Stocktake '{0}' has no line for lot '{1}' at location '{2}'.
+
+
+ Transfer order not found.
+
+
+ One row is one line. Lots and locations are allocated automatically by first-in first-out (earliest expiry first)
+
+
+ ProcessIssue (issue to process) / Shipping. Defaults to ProcessIssue. The value on the first row of the group is used
+
+
+ true executes the picking and issues the stock (value on the first row of the group)
+
+
+ true finalizes after registering the counts and applies the differences to stock (value on the first row of the group)
+
+
+ true moves the stock and completes the order. Blank leaves it as instructed
+
+
+ Picking orders
+
+
+ Picking number
+
+
+ Required for shipping picking
+
+
+ Rows with the same number form one order. Numbers starting with PK are reserved for automatic numbering
+
+
+ Stocktake counts
+
+
+ Counted quantity
+
+
+ Target location code
+
+
+ Required for issue to process
+
+
+ Required for issue to process (specifies the destination work order together with the routing sequence)
+
+
+ Referenced from the stocktake count CSV that follows. Numbers starting with ST are reserved for automatic numbering
+
+
+ Issue type
+
+
+ Lot to transfer
+
+
+ Transfer orders
+
+
+ Location of the stocktake line
+
+
+ Lot of the stocktake line
+
+
+ Stocktakes
+
+
+ Stocktake number
+
+
+ A registered, active location
+
+
+ A registered stocktake (StocktakeNo in the stocktake CSV). Rows with the same number are registered together
+
+
+ Finalize
+
+
+ Execute the move
+
+
+ Source location code
+
+
+ Blank means all locations. The stock at the time of creation becomes the lines
+
+
+ Only quality control staff can register nonconformance action instructions and approvals.
+
+
+ Either ReportNo (nonconformance number) or LotNumber (lot number) is required.
+
+
+ A row that raises a nonconformance needs Content.
+
+
+ Lot '{0}' has no open nonconformance.
+
+
+ Lot '{0}' has {1} open nonconformances. Specify one with ReportNo.
+
+
+ A row that collects a sample needs LotNumber, Quantity and StorageLocationCode.
+
+
+ Sample number '{0}' already exists.
+
+
+ Sample number '{0}' cannot be specified because it overlaps the auto-numbering format ({1}...).
+
+
+ Sample not found.
+
+
+ Consumed / Disposed. Writing it ends the storage (stock does not move)
+
+
+ true approves and closes it (special acceptance returns the lot to normal). Quality control staff only
+
+
+ Sample storage
+
+
+ Nonconformances
+
+
+ Nonconformance number
+
+
+ End of storage
+
+
+ Storage location code
+
+
+ Retain until
+
+
+ Cause details
+
+
+ Cause category
+
+
+ Action
+
+
+ Action record
+
+
+ Action instruction
+
+
+ Required when collecting. A number greater than 0
+
+
+ Required when collecting. Storage shelves are not used for allocation
+
+
+ Required when collecting. Taken from the location with the most stock
+
+
+ Writing it records the action performed (the action must have been instructed)
+
+
+ Writing it issues an action instruction. Rework / Hold / Discard / SpecialAcceptance. Quality control staff only
+
+
+ Collects the sample if unregistered; if registered, only ends the storage (Close). Numbers starting with SP are reserved for automatic numbering
+
+
+ Source
+
+
+ Refers to the nonconformance if registered, otherwise raises a new one (numbers starting with NC are reserved for automatic numbering). If blank, LotNumber is used
+
+
+ The target lot when raising. On a row with a blank ReportNo, refers to the lot's open nonconformance (only when there is exactly one)
+
+
+ To link a raised nonconformance to a work order (specify with the routing sequence)
+
+
+ When raising
+
+
+ When raising. Production / Inspection / Receiving. Defaults to Production
+
+
+ Required when raising
+
+
+ An order created from a maintenance plan must have the request type Planned.
+
+
+ An order created from a maintenance plan needs EquipmentAssetNo (equipment) and ScheduledDate (scheduled date).
+
+
+ There is no uninstructed maintenance plan matching the equipment and scheduled date {0}.
+
+
+ There are {1} uninstructed maintenance plans matching the equipment and scheduled date {0}. Give them different scheduled dates.
+
+
+ Either PlanYear (plan year) or ScheduledDate (scheduled date) is required.
+
+
+ Periodic / Unplanned. Defaults to Periodic
+
+
+ true creates the order from the uninstructed maintenance plan matching the equipment and scheduled date (only when there is exactly one) and marks the plan as ordered. The request type becomes Planned
+
+
+ yyyy-MM-dd. FromPlan in the maintenance order CSV refers to a plan by this date and the equipment, so give plans for the same equipment different dates
+
+
+ Maintenance category
+
+
+ Maintenance plans
+
+
+ Create from maintenance plan
+
+
+ Cycle (days)
+
+
+ Cycle of periodic maintenance
+
+
+ Defaults to the year of the scheduled date. Required if there is no scheduled date
+
+
+ Plan year
+
diff --git a/src/MesApp.Api/MesAppHost.cs b/src/MesApp.Api/MesAppHost.cs
index 4743996..f751548 100644
--- a/src/MesApp.Api/MesAppHost.cs
+++ b/src/MesApp.Api/MesAppHost.cs
@@ -101,6 +101,9 @@ public static WebApplication Build(string[] args)
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services
diff --git a/src/MesApp.Api/Services/ActualCsvKinds.cs b/src/MesApp.Api/Services/ActualCsvKinds.cs
index dca7088..955c020 100644
--- a/src/MesApp.Api/Services/ActualCsvKinds.cs
+++ b/src/MesApp.Api/Services/ActualCsvKinds.cs
@@ -31,6 +31,19 @@ public static class ActualCsvKinds
public const string ShippingOrders = "shipping-orders";
public const string ShipmentJudgments = "shipment-judgments";
public const string Shipments = "shipments";
+ public const string MaintenancePlans = "maintenance-plans";
+ public const string MaintenanceOrders = "maintenance-orders";
+ public const string MaintenanceRecords = "maintenance-records";
+ public const string ToolUsages = "tool-usages";
+ public const string ToolIssues = "tool-issues";
+ public const string Calibrations = "calibrations";
+ public const string InventoryOperations = "inventory-operations";
+ public const string TransferOrders = "transfer-orders";
+ public const string PickingOrders = "picking-orders";
+ public const string Stocktakes = "stocktakes";
+ public const string StocktakeCounts = "stocktake-counts";
+ public const string Nonconformances = "nonconformances";
+ public const string SampleStorages = "sample-storages";
private const string OrderNoNote = "登録済みの指図番号(製造指図CSVの OrderNo)";
private const string SequenceNote = "工順の工程順序。指図番号と合わせて作業指示を指す(展開済みであること)";
@@ -186,6 +199,153 @@ public static class ActualCsvKinds
new("LocationCode", "出荷元ロケーションコード", true, null),
new("Quantity", "出荷数量", true, "0より大きい数値。出荷済みと合わせて指示数量を超えられない"),
]), MesRoleGroups.InventoryManage),
+ new(new CsvKindInfo(MaintenancePlans, "保全計画", false,
+ [
+ new("EquipmentAssetNo", "設備の資産番号", true, "登録済みで有効な設備"),
+ new("Category", "保全種別", false, "Periodic(定期)/ Unplanned(計画外)。省略時は定期"),
+ new("PlanYear", "計画年度", false, "省略時は予定日の年。予定日も無ければ必須"),
+ new("ScheduledDate", "予定日", false,
+ "yyyy-MM-dd。保全指示CSVの FromPlan はこの日付と設備で計画を指すので、同じ設備の計画は予定日を分ける"),
+ new("CycleDays", "周期(日)", false, "定期保全の周期"),
+ new("Note", "備考", false, null),
+ ]), MesRoleGroups.MaintenanceManage),
+ new(new CsvKindInfo(MaintenanceOrders, "保全指示・突発依頼", false,
+ [
+ new("MaintenanceNo", "保全指示番号", true,
+ "後続の保全実績CSVから指示を指す。MTで始まる番号は自動採番用のため使えない"),
+ new("EquipmentAssetNo", "対象設備の資産番号", false, "対象治工具コードとどちらか一方は必須"),
+ new("ToolCode", "対象治工具コード", false, "治工具メンテナンスの指示のとき"),
+ new("RequestType", "依頼区分", false,
+ "Planned(計画)/ Spot(突発依頼)。省略時は突発依頼。計画は保全担当者だけが登録できる"),
+ new("ProcedureNo", "保全手順書番号", false, "登録済みで有効な保全手順書"),
+ new("ScheduledDate", "予定日", false, "yyyy-MM-dd"),
+ new("Note", "備考", false, null),
+ new("FromPlan", "保全計画から作る", false,
+ "true で、設備と予定日が一致する未指示の保全計画(1件だけのとき)から作り、計画を指示済みにする。依頼区分は計画になる"),
+ ]), AnyRole),
+ new(new CsvKindInfo(MaintenanceRecords, "保全実績", false,
+ [
+ new("MaintenanceNo", "保全指示番号", true,
+ "登録済みの保全指示(保全指示CSVの MaintenanceNo)。同じ番号の行を1件の実績にまとめ、登録で指示は完了する"),
+ new("StartedAt", "開始日時", true, DateTimeNote + "。まとまりの最初の行の値を使う"),
+ new("EndedAt", "終了日時", false, DateTimeNote),
+ new("Result", "実施結果", false, null),
+ new("PartsUsed", "使用部品の補足", false, "自由記述(在庫を動かす部材は Part〜 の列に書く)"),
+ new("Note", "備考", false, null),
+ new("ResetToolLife", "寿命をリセットする", false, "true で治工具の寿命カウンタをリセットする(治工具メンテナンスの指示のみ)"),
+ new("PartLotNumber", "消費部材のロット番号", false,
+ "1行に1ロット。在庫から引き落とす。設備の資産管理部品・消耗品の区分は単票と同じに判定する"),
+ new("PartLocationCode", "払出元ロケーションコード", false, "消費部材を書くときは必須"),
+ new("PartQuantity", "消費数量", false, "消費部材を書くときは必須"),
+ new("PartNote", "部材の備考", false, null),
+ ]), AnyRole),
+ new(new CsvKindInfo(ToolUsages, "治工具の利用実績", false,
+ [
+ new("ToolCode", "治工具コード", true, "登録済みで有効な治工具"),
+ new("OrderNo", "指図番号", false, "作業指示に紐づける場合(工程順序と合わせて指す)"),
+ new("Sequence", "工程順序", false, "指図番号を書いたときは必須"),
+ new("UsageCount", "使用回数", false, "0以上の整数。使用時間とどちらかは0より大きいこと"),
+ new("UsageHours", "使用時間", false, "0以上"),
+ new("RecordedAt", "記録日時", false,
+ DateTimeNote + "。空欄なら取り込んだ時刻。寿命の累計はメンテナンスで寿命をリセットした時刻より後の記録だけを数える"),
+ ]), AnyRole),
+ new(new CsvKindInfo(ToolIssues, "治工具の引当・払出", false,
+ [
+ new("ToolCode", "治工具コード", true, "使用中・メンテナンス中・寿命到達の治工具は引き当てられない"),
+ new("OrderNo", "指図番号", true, OrderNoNote),
+ new("Sequence", "工程順序", true, SequenceNote),
+ new("Issue", "払い出す", false, "true で引当に続けて払出・受領確認まで進める(受領者は取り込んだユーザー)"),
+ new("Return", "返却する", false, "true で返却まで進める(治工具は再び引当できるようになる)"),
+ new("Note", "備考", false, null),
+ ]), MesRoleGroups.ProductionManage),
+ new(new CsvKindInfo(Calibrations, "検査機の校正", false,
+ [
+ new("DeviceCode", "検査機コード", true, "登録済みの検査機・測定器"),
+ new("CalibratedOn", "校正日", true, "yyyy-MM-dd。検査機の最終校正日になる"),
+ new("NextDueOn", "次回校正期限", false, "yyyy-MM-dd。空欄なら校正日+校正周期(周期も無ければ期限なし)"),
+ new("Result", "校正結果", false, "合格・調整後合格 など"),
+ ]), MesRoleGroups.QualityManage),
+ new(new CsvKindInfo(InventoryOperations, "在庫オペレーション", false,
+ [
+ new("Operation", "操作", true,
+ "Move(移動)/ Adjust(数量調整)/ Status(状態変更)/ Split(分割)/ Merge(統合)/ Transfer(振替)/ Discard(廃棄)/ Return(返品)/ IssueReturn(払出戻し)"),
+ new("LotNumber", "ロット番号", true, "操作するロット(統合では統合元)。前の行で分割・振替したロットも指せる"),
+ new("LocationCode", "ロケーションコード", false, "操作する在庫の場所(移動元・払出戻しの戻し先)。状態変更以外で必須"),
+ new("ToLocationCode", "移動先ロケーションコード", false, "移動で必須"),
+ new("Quantity", "数量", false, "状態変更・統合以外で必須。数量調整では調整後の数量(0以上)、ほかは0より大きい数値"),
+ new("NewLotNumber", "新ロット番号", false, "分割・振替で作るロットの番号。空欄なら自動採番(後の行から指すなら指定する)"),
+ new("TargetLotNumber", "統合先ロット番号", false, "統合で必須。同じ品目・同じロケーションのロット"),
+ new("ProductCode", "振替先品目コード", false, "品目振替のとき。振替では振替先品目か新ロット番号のどちらかが必要"),
+ new("Status", "在庫状態", false,
+ "状態変更で必須。Normal(正常)/ OnHold(保留)/ AwaitingInspection(検査待ち)/ Defective(不良)/ ToBeDiscarded(廃棄予定)"),
+ new("OrderNo", "指図番号", false, "払出戻しで作業指示を記録する場合(工程順序と合わせて指す)"),
+ new("Sequence", "工程順序", false, "指図番号を書いたときは必須"),
+ new("Reason", "理由", false, "数量調整で必須。状態変更・廃棄・返品では状態履歴・在庫履歴に残る"),
+ ]), MesRoleGroups.InventoryManage),
+ new(new CsvKindInfo(TransferOrders, "搬送指示", false,
+ [
+ new("LotNumber", "ロット番号", true, "搬送するロット"),
+ new("FromLocationCode", "移動元ロケーションコード", true, null),
+ new("ToLocationCode", "移動先ロケーションコード", true, "登録済みで有効なロケーション"),
+ new("Quantity", "数量", true, "0より大きい数値"),
+ new("Execute", "移動を実行する", false, "true で指示に続けて在庫を移動し完了にする。空欄なら指示のまま残る"),
+ ]), MesRoleGroups.InventoryManage),
+ new(new CsvKindInfo(PickingOrders, "ピッキング指示", false,
+ [
+ new("PickingNo", "ピッキング番号", true,
+ "同じ番号の行を1件の指示にまとめる。PKで始まる番号は自動採番用のため使えない"),
+ new("Type", "払出区分", false,
+ "ProcessIssue(工程払出)/ Shipping(出荷)。省略時は工程払出。まとまりの最初の行の値を使う"),
+ new("OrderNo", "指図番号", false, "工程払出で必須(工程順序と合わせて払出先の作業指示を指す)"),
+ new("Sequence", "工程順序", false, "工程払出で必須"),
+ new("ShippingNo", "出荷番号", false, "出荷ピッキングで必須"),
+ new("ProductCode", "品目コード", true, "1行=1明細。ロット・ロケーションは先入れ先出し(有効期限優先)で自動引当する"),
+ new("Quantity", "数量", true, "0より大きい数値"),
+ new("Execute", "払い出す", false, "true でピッキングを実行し在庫を引き落とす(まとまりの最初の行の値)"),
+ ]), MesRoleGroups.InventoryManage),
+ new(new CsvKindInfo(Stocktakes, "棚卸指示", false,
+ [
+ new("StocktakeNo", "棚卸番号", true,
+ "後続の実棚数CSVから指示を指す。STで始まる番号は自動採番用のため使えない"),
+ new("LocationCode", "対象ロケーションコード", false, "空欄なら全ロケーション。作成時点の現在庫が明細になる"),
+ ]), MesRoleGroups.InventoryManage),
+ new(new CsvKindInfo(StocktakeCounts, "実棚数", false,
+ [
+ new("StocktakeNo", "棚卸番号", true, "登録済みの棚卸(棚卸指示CSVの StocktakeNo)。同じ番号の行をまとめて登録する"),
+ new("LotNumber", "ロット番号", true, "棚卸の明細のロット"),
+ new("LocationCode", "ロケーションコード", true, "棚卸の明細のロケーション"),
+ new("CountedQuantity", "実棚数量", true, "0以上"),
+ new("Finalize", "確定する", false, "true で実棚数の登録に続けて確定し、差異を在庫へ反映する(まとまりの最初の行の値)"),
+ ]), MesRoleGroups.InventoryManage),
+ new(new CsvKindInfo(Nonconformances, "不適合", false,
+ [
+ new("ReportNo", "不適合番号", false,
+ "登録済みならその不適合を指し、未登録なら新規に起票する(NCで始まる番号は自動採番用のため使えない)。空欄なら LotNumber で指す"),
+ new("LotNumber", "ロット番号", false,
+ "起票では対象ロット。ReportNo が空欄の行では、そのロットの未完了の不適合(1件だけのとき)を指す"),
+ new("Source", "発生元", false, "起票のとき。Production(生産実績)/ Inspection(検査)/ Receiving(受入)。省略時は生産実績"),
+ new("Content", "内容", false, "起票のときは必須"),
+ new("OrderNo", "指図番号", false, "起票で作業指示に紐づける場合(工程順序と合わせて指す)"),
+ new("Sequence", "工程順序", false, "指図番号を書いたときは必須"),
+ new("CauseCategory", "原因区分", false, "起票のとき"),
+ new("CauseDetail", "原因の詳細", false, "起票のとき"),
+ new("Action", "対応", false,
+ "書くと対応指示を出す。Rework(リワーク)/ Hold(保留)/ Discard(廃棄)/ SpecialAcceptance(特採)。品質管理の担当者のみ"),
+ new("ActionInstruction", "対応指示の内容", false, null),
+ new("ActionRecord", "対応の実施記録", false, "書くと対応実績を記録する(対応指示済みであること)"),
+ new("Approve", "承認する", false, "true で承認してクローズする(特採ならロットを正常へ戻す)。品質管理の担当者のみ"),
+ ]), AnyRole),
+ new(new CsvKindInfo(SampleStorages, "サンプル品保管", false,
+ [
+ new("SampleNo", "サンプル番号", true,
+ "未登録なら採取、登録済みなら保管の終了(Close)だけを行う。SPで始まる番号は自動採番用のため使えない"),
+ new("LotNumber", "ロット番号", false, "採取のときは必須。在庫の多いロケーションから抜く"),
+ new("Quantity", "数量", false, "採取のときは必須。0より大きい数値"),
+ new("StorageLocationCode", "保管場所のロケーションコード", false, "採取のときは必須。保管棚は引当の対象にならない"),
+ new("RetainUntil", "保管期限", false, "yyyy-MM-dd。空欄なら期限の判定を行わない"),
+ new("Note", "備考", false, null),
+ new("Close", "保管の終了", false, "Consumed(払出)/ Disposed(廃棄)。書くと保管を終える(在庫は動かさない)"),
+ ]), MesRoleGroups.QualityManage),
];
///
diff --git a/src/MesApp.Api/Services/ActualCsvService.Execution.cs b/src/MesApp.Api/Services/ActualCsvService.Execution.cs
new file mode 100644
index 0000000..7cd674e
--- /dev/null
+++ b/src/MesApp.Api/Services/ActualCsvService.Execution.cs
@@ -0,0 +1,301 @@
+using MesApp.Api.Localization;
+using System.Globalization;
+using MesApp.Core.Contracts.Execution;
+using MesApp.Core.Contracts.Masters;
+using MesApp.Core.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+// 実績CSVのうち作業指示の実行記録(B-20-50 / B-30-10 / B-30-20 / B-40-10 / B-30-30-04。WorkOrderExecutionService)
+public sealed partial class ActualCsvService
+{
+ /// 段取り実績。1行=1記録
+ private async Task ImportSetupRecordsAsync(
+ CsvTable table, List errors, string userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var workOrderId = ResolveWorkOrder(reader, workOrders);
+ reader.RequiredText("Type");
+ var type = reader.Enum("Type", SetupType.Pre, CsvEnumLabels.SetupTypes);
+ var startedAt = RequiredDateTime(reader, "StartedAt");
+ var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
+ var note = reader.Text("AbnormalityNote", null, 1000);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await execution.AddSetupRecordAsync(
+ workOrderId!.Value, new SetupRecordRequest(type, startedAt!.Value, endedAt, note), userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// チェックリスト実施。指図番号・工程順序・チェックリストコードが同じ行を1回の実施としてまとめる
+ /// (マスタCSVのチェックリストと同じく、1行=1項目)
+ ///
+ private async Task ImportChecklistRecordsAsync(
+ CsvTable table, List errors, string userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var checklists = await db.Checklists.AsNoTracking().Include(c => c.Items)
+ .ToDictionaryAsync(c => c.Code, StringComparer.Ordinal, ct);
+
+ var groups = table.Rows
+ .GroupBy(row => (table.Value(row, "OrderNo"), table.Value(row, "Sequence"), table.Value(row, "ChecklistCode")))
+ .Select(g => g.ToList())
+ .ToList();
+
+ var created = 0;
+ foreach (var group in groups)
+ {
+ var head = new CsvRowReader(table, group[0], errors);
+ var workOrderId = ResolveWorkOrder(head, workOrders);
+ var code = head.RequiredText("ChecklistCode");
+ if (head.Failed)
+ {
+ continue;
+ }
+ if (!checklists.TryGetValue(code, out var checklist))
+ {
+ head.Fail(ApiText.T("チェックリスト '{0}' は登録されていません(ChecklistCode)。", code));
+ continue;
+ }
+
+ var results = new List();
+ var failed = false;
+ foreach (var row in group)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var itemSequence = reader.IntOrNull("ItemSequence", null, 1);
+ var isChecked = reader.Bool("IsChecked", true);
+ var note = reader.Text("Note", null, 500);
+ if (itemSequence is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("ItemSequence(項目の表示順)は必須です。"));
+ }
+ var item = checklist.Items.FirstOrDefault(i => i.Sequence == itemSequence);
+ if (itemSequence is not null && item is null)
+ {
+ reader.Fail(ApiText.T("チェックリスト '{0}' に表示順 {1} の項目はありません。", code, itemSequence));
+ }
+ else if (item is not null && results.Any(r => r.ChecklistItemId == item.Id))
+ {
+ reader.Fail(ApiText.T("チェックリスト '{0}' の表示順 {1} が重複しています。", code, itemSequence));
+ }
+ if (reader.Failed)
+ {
+ failed = true;
+ continue;
+ }
+ results.Add(new ChecklistResultRequest(item!.Id, isChecked, note));
+ }
+ if (failed)
+ {
+ continue;
+ }
+
+ var outcome = await execution.AddChecklistRecordAsync(
+ workOrderId!.Value, new ChecklistRecordRequest(checklist.Id, results), userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(head, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 部材投入。1行=1ロットの投入(在庫の払出と同時)
+ private async Task ImportConsumptionsAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var locationIds = await db.Locations.AsNoTracking()
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var workOrderId = ResolveWorkOrder(reader, workOrders);
+ var lotNumber = reader.RequiredText("LotNumber");
+ reader.RequiredText("LocationCode");
+ var locationId = reader.Reference("LocationCode", null, locationIds, "ロケーション");
+ var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
+ var substituteReason = reader.Text("SubstituteReason", null, 500);
+ if (quantity is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("Quantity(投入数量)は必須です。"));
+ }
+ // 前のファイル(受入・工程展開)や前の行で作られたロットも指せるよう、行ごとにDBを引く
+ int? lotId = null;
+ if (lotNumber.Length > 0)
+ {
+ lotId = await db.Lots.Where(l => l.LotNumber == lotNumber)
+ .Select(l => (int?)l.Id).FirstOrDefaultAsync(ct);
+ if (lotId is null)
+ {
+ reader.Fail(ApiText.T("ロット '{0}' は登録されていません(LotNumber)。", lotNumber));
+ }
+ }
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await execution.AddConsumptionAsync(workOrderId!.Value,
+ new ConsumptionRequest(lotId!.Value, locationId!.Value, quantity!.Value, substituteReason), userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 生産実績。1行=1回の実績報告(分割報告は行を分ける)
+ private async Task ImportProductionRecordsAsync(
+ CsvTable table, List errors, string userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var locationIds = await db.Locations.AsNoTracking()
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+ var defectReasonIds = await db.DefectReasons.AsNoTracking()
+ .ToDictionaryAsync(r => r.Code, r => r.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var workOrderId = ResolveWorkOrder(reader, workOrders);
+ reader.RequiredText("GoodQuantity");
+ var good = reader.Number("GoodQuantity", 0m, 0);
+ var defect = reader.Number("DefectQuantity", 0m, 0);
+ var scrap = reader.Number("ScrapQuantity", 0m, 0);
+ var rework = reader.Number("ReworkQuantity", 0m, 0);
+ var startedAt = RequiredDateTime(reader, "StartedAt");
+ var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
+ var locationId = reader.Reference("OutputLocationCode", null, locationIds, "入庫先ロケーション");
+ var backflush = reader.Bool("Backflush", false);
+ var defects = ParseDefects(reader, table.Value(row, "Defects"), defectReasonIds);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await execution.AddProductionRecordAsync(workOrderId!.Value,
+ new ProductionRecordRequest(good, defect, startedAt!.Value, endedAt, locationId, backflush,
+ scrap, rework, defects), userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 製造条件データ。1行=1項目の記録
+ private async Task ImportDataRecordsAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var workOrderId = ResolveWorkOrder(reader, workOrders);
+ var controlItemCode = reader.Text("ControlItemCode", null);
+ var numericValue = reader.NumberOrNull("NumericValue", null);
+ var item = reader.Text("Item", null, 100);
+ var value = reader.Text("Value", null, 500);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ int? instructionId = null;
+ if (controlItemCode is not null)
+ {
+ // 展開時に作業指示へ写した指示(スナップショット)を引く。マスタの現在値では判定しない(Spec.md 5.7)
+ var instruction = await db.WorkOrderControlItems.AsNoTracking()
+ .FirstOrDefaultAsync(i => i.WorkOrderId == workOrderId && i.ItemCode == controlItemCode, ct);
+ if (instruction is null)
+ {
+ reader.Fail(ApiText.T("工程管理項目 '{0}' はこの作業指示に展開されていません(ControlItemCode)。", controlItemCode));
+ continue;
+ }
+ if (numericValue is null)
+ {
+ reader.Fail(ApiText.T("ControlItemCode を指定した行には NumericValue(数値)が必要です。"));
+ continue;
+ }
+ instructionId = instruction.Id;
+ item ??= instruction.ItemName;
+ value ??= $"{numericValue}{instruction.Unit}";
+ }
+ if (item is null || value is null)
+ {
+ reader.Fail(ApiText.T("ControlItemCode を省略した行には Item(項目)と Value(値)が必要です。"));
+ continue;
+ }
+
+ var outcome = await execution.AddDataRecordsAsync(workOrderId!.Value,
+ [new DataRecordRequest(item, value, instructionId, numericValue)], userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 不良理由別の内訳(DR-02=2;DR-03=1)を読む
+ private static List? ParseDefects(
+ CsvRowReader reader, string? raw, IReadOnlyDictionary reasonIds)
+ {
+ if (raw is null)
+ {
+ return null;
+ }
+ var result = new List();
+ foreach (var part in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var pair = part.Split('=', 2, StringSplitOptions.TrimEntries);
+ if (pair.Length != 2
+ || !decimal.TryParse(pair[1], NumberStyles.Number, CultureInfo.InvariantCulture, out var quantity)
+ || quantity < 0)
+ {
+ reader.Fail(ApiText.T("Defects は 不良理由コード=数量 をセミコロンで区切って指定してください('{0}')。", part));
+ continue;
+ }
+ if (!reasonIds.TryGetValue(pair[0], out var reasonId))
+ {
+ reader.Fail(ApiText.T("不良理由 '{0}' は登録されていません(Defects)。", pair[0]));
+ continue;
+ }
+ result.Add(new ProductionDefectRequest(reasonId, quantity));
+ }
+ return result;
+ }
+}
diff --git a/src/MesApp.Api/Services/ActualCsvService.Inventory.cs b/src/MesApp.Api/Services/ActualCsvService.Inventory.cs
new file mode 100644
index 0000000..fa02be5
--- /dev/null
+++ b/src/MesApp.Api/Services/ActualCsvService.Inventory.cs
@@ -0,0 +1,176 @@
+using MesApp.Api.Localization;
+using MesApp.Core.Contracts.Inventory;
+using MesApp.Core.Contracts.Masters;
+using MesApp.Core.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+// 実績CSVのうち在庫(D-10-10-02 受入、D-10-30 在庫オペレーション)
+public sealed partial class ActualCsvService
+{
+ /// 在庫オペレーションCSVの操作(Operation 列。単票の api/inventory/{操作} と1対1)
+ private enum InventoryOperation { Move, Adjust, Status, Split, Merge, Transfer, Discard, Return, IssueReturn }
+
+ private static readonly IReadOnlyDictionary InventoryOperations =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Move"] = InventoryOperation.Move, ["移動"] = InventoryOperation.Move,
+ ["Adjust"] = InventoryOperation.Adjust, ["調整"] = InventoryOperation.Adjust,
+ ["Status"] = InventoryOperation.Status, ["状態変更"] = InventoryOperation.Status,
+ ["Split"] = InventoryOperation.Split, ["分割"] = InventoryOperation.Split,
+ ["Merge"] = InventoryOperation.Merge, ["統合"] = InventoryOperation.Merge,
+ ["Transfer"] = InventoryOperation.Transfer, ["振替"] = InventoryOperation.Transfer,
+ ["Discard"] = InventoryOperation.Discard, ["廃棄"] = InventoryOperation.Discard,
+ ["Return"] = InventoryOperation.Return, ["返品"] = InventoryOperation.Return,
+ ["IssueReturn"] = InventoryOperation.IssueReturn, ["払出戻し"] = InventoryOperation.IssueReturn,
+ };
+
+ /// 操作ごとの必須列(単票の要求で必須の項目。数量調整の理由は API でも必須)
+ private static string[] RequiredColumnsOf(InventoryOperation operation) => operation switch
+ {
+ InventoryOperation.Move => ["LocationCode", "ToLocationCode", "Quantity"],
+ InventoryOperation.Adjust => ["LocationCode", "Quantity", "Reason"],
+ InventoryOperation.Status => ["Status"],
+ InventoryOperation.Merge => ["LocationCode", "TargetLotNumber"],
+ _ => ["LocationCode", "Quantity"],
+ };
+
+ ///
+ /// 在庫オペレーション(D-10-30 移動・調整・状態変更・分割・統合・振替、D-50-30-01 廃棄、D-10-10-05 返品、
+ /// D-20-20-03 払出戻し)。1行=1操作で、行ごとに単票と同じ の同名メソッドを呼ぶ。
+ /// 分割・振替の新ロット番号(NewLotNumber)を指定すれば、同じファイルの後の行からそのロットを指せる
+ ///
+ private async Task ImportInventoryOperationsAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var locationIds = await db.Locations.AsNoTracking()
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+ var productIds = await db.Products.AsNoTracking().Where(p => p.IsActive)
+ .ToDictionaryAsync(p => p.Code, p => p.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("Operation");
+ var operation = reader.Enum("Operation", InventoryOperation.Move, InventoryOperations);
+ var lotNumber = reader.RequiredText("LotNumber");
+ var locationId = reader.Reference("LocationCode", null, locationIds, "ロケーション");
+ var toLocationId = reader.Reference("ToLocationCode", null, locationIds, "ロケーション");
+ // 数量調整は調整後の数量(0 で在庫を無くす)、ほかは操作する数量
+ var quantity = reader.NumberOrNull("Quantity", null, 0m);
+ var newLotNumber = reader.Text("NewLotNumber", null, 60);
+ var targetLotNumber = reader.Text("TargetLotNumber", null);
+ var productId = reader.Reference("ProductCode", null, productIds, "有効な品目");
+ var status = reader.Enum("Status", LotStockStatus.Normal, CsvEnumLabels.LotStockStatuses);
+ var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
+ var reason = reader.Text("Reason", null, 500);
+ if (reader.Failed)
+ {
+ continue;
+ }
+ var missing = RequiredColumnsOf(operation).Where(c => table.Value(row, c) is null).ToList();
+ if (missing.Count > 0)
+ {
+ reader.Fail(ApiText.T("操作 '{0}' には {1} が必要です。", operation, string.Join(", ", missing)));
+ continue;
+ }
+ if (operation != InventoryOperation.Adjust && quantity == 0)
+ {
+ reader.Fail(ApiText.T("Quantity(数量)は0より大きい値にしてください。"));
+ continue;
+ }
+ // 前の行の分割・振替で作ったロットも指せるよう、行ごとにDBを引く
+ var lotId = await LotIdAsync(reader, lotNumber, "LotNumber", ct);
+ var targetLotId = targetLotNumber is null ? null : await LotIdAsync(reader, targetLotNumber, "TargetLotNumber", ct);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var lot = lotId!.Value;
+ var outcome = operation switch
+ {
+ InventoryOperation.Move => await lotOperations.MoveAsync(
+ lot, locationId!.Value, toLocationId!.Value, quantity!.Value, userId, ct),
+ InventoryOperation.Adjust => await lotOperations.AdjustAsync(
+ lot, locationId!.Value, quantity!.Value, reason, userId, ct),
+ InventoryOperation.Status => await lotOperations.ChangeStatusAsync(lot, status, reason, userId, ct),
+ InventoryOperation.Split => await lotOperations.SplitAsync(
+ lot, locationId!.Value, quantity!.Value, newLotNumber, userId, ct),
+ InventoryOperation.Merge => await lotOperations.MergeAsync(lot, targetLotId!.Value, locationId!.Value, userId, ct),
+ InventoryOperation.Transfer => await lotOperations.TransferAsync(
+ lot, locationId!.Value, quantity!.Value, productId, newLotNumber, userId, ct),
+ InventoryOperation.Discard => await lotOperations.DiscardAsync(
+ lot, locationId!.Value, quantity!.Value, reason, userId, ct),
+ InventoryOperation.Return => await lotOperations.ReturnAsync(
+ lot, locationId!.Value, quantity!.Value, reason, userId, ct),
+ _ => await lotOperations.IssueReturnAsync(lot, locationId!.Value, quantity!.Value, workOrderId, userId, ct),
+ };
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// ロット番号→ロットID(無ければ行エラー)
+ private async Task LotIdAsync(CsvRowReader reader, string lotNumber, string column, CancellationToken ct)
+ {
+ var lotId = await db.Lots.Where(l => l.LotNumber == lotNumber).Select(l => (int?)l.Id).FirstOrDefaultAsync(ct);
+ if (lotId is null)
+ {
+ reader.Fail(ApiText.T("ロット '{0}' は登録されていません({1})。", lotNumber, column));
+ }
+ return lotId;
+ }
+
+ /// 受入(D-10-10-02)。1行=1ロットの受入登録
+ private async Task ImportReceivingAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ // 無効なマスタは候補に入れない(単票APIも無効な品目・ロケーションへの受入を拒否する)
+ var productIds = await db.Products.AsNoTracking().Where(p => p.IsActive)
+ .ToDictionaryAsync(p => p.Code, p => p.Id, StringComparer.Ordinal, ct);
+ var locationIds = await db.Locations.AsNoTracking().Where(l => l.IsActive)
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("ProductCode");
+ var productId = reader.Reference("ProductCode", null, productIds, "有効な品目");
+ var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
+ reader.RequiredText("LocationCode");
+ var locationId = reader.Reference("LocationCode", null, locationIds, "有効なロケーション");
+ var lotNumber = reader.Text("LotNumber", null, 60);
+ var expiresOn = reader.DateOrNull("ExpiresOn", null);
+ var note = reader.Text("Note", null, 500);
+ if (quantity is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("Quantity(受入数量)は必須です。"));
+ }
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await receiving.ReceiveAsync(
+ new ReceivingRequest(productId!.Value, quantity!.Value, locationId!.Value, lotNumber, expiresOn, note),
+ userId, ct);
+ if (outcome.Error is not null)
+ {
+ FailRow(reader, outcome.Error);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+}
diff --git a/src/MesApp.Api/Services/ActualCsvService.Logistics.cs b/src/MesApp.Api/Services/ActualCsvService.Logistics.cs
new file mode 100644
index 0000000..7937dcc
--- /dev/null
+++ b/src/MesApp.Api/Services/ActualCsvService.Logistics.cs
@@ -0,0 +1,247 @@
+using MesApp.Api.Localization;
+using MesApp.Core.Contracts.Execution;
+using MesApp.Core.Contracts.Inventory;
+using MesApp.Core.Contracts.Masters;
+using MesApp.Core.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+// 実績CSVのうち物流の指示(B-50-10 搬送、D-20 ピッキング・払出、D-50-10 棚卸)。
+// 搬送は TransferOrderService、ピッキングは PickingService、棚卸は StocktakeService を通す
+public sealed partial class ActualCsvService
+{
+ ///
+ /// 搬送指示(B-50-10-01〜02)。1行=1件の指示で、Execute=true なら移動の実行まで進める。
+ /// 搬送指示には番号が無いので、後のファイルからは指さない
+ ///
+ private async Task ImportTransferOrdersAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var locationIds = await db.Locations.AsNoTracking()
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var lotNumber = reader.RequiredText("LotNumber");
+ reader.RequiredText("FromLocationCode");
+ var fromId = reader.Reference("FromLocationCode", null, locationIds, "ロケーション");
+ reader.RequiredText("ToLocationCode");
+ var toId = reader.Reference("ToLocationCode", null, locationIds, "ロケーション");
+ var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
+ var execute = reader.Bool("Execute", false);
+ if (quantity is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("Quantity(数量)は必須です。"));
+ }
+ var lotId = lotNumber.Length == 0 ? null : await LotIdAsync(reader, lotNumber, "LotNumber", ct);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await transfers.CreateAsync(
+ new TransferOrderRequest(lotId!.Value, quantity!.Value, fromId!.Value, toId!.Value), userId, ct);
+ if (!outcome.Failed && execute)
+ {
+ outcome = await transfers.ExecuteAsync(outcome.Value!.Id, userId, ct);
+ }
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// ピッキング指示(D-20-10-01〜02。出荷ピッキングは D-40-20)。ピッキング番号が同じ行を1件の指示にまとめ、
+ /// 1行=1明細(品目と数量)。ロット・ロケーションは単票と同じく先入れ先出し(有効期限優先)で自動引当する。
+ /// Execute=true なら払出まで進める
+ ///
+ private async Task ImportPickingOrdersAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var productIds = await db.Products.AsNoTracking().Where(p => p.IsActive)
+ .ToDictionaryAsync(p => p.Code, p => p.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var group in table.Rows.GroupBy(row => table.Value(row, "PickingNo")).Select(g => g.ToList()))
+ {
+ var head = new CsvRowReader(table, group[0], errors);
+ var pickingNo = head.RequiredText("PickingNo", 50);
+ var type = head.Enum("Type", PickingOrderType.ProcessIssue, CsvEnumLabels.PickingOrderTypes);
+ var execute = head.Bool("Execute", false);
+ int? workOrderId = null;
+ int? shippingOrderId = null;
+ if (type == PickingOrderType.ProcessIssue)
+ {
+ workOrderId = ResolveWorkOrder(head, workOrders);
+ }
+ else if (head.Text("ShippingNo", null) is not { } shippingNo)
+ {
+ head.Fail(ApiText.T("出荷ピッキングには ShippingNo(出荷番号)が必要です。"));
+ }
+ else
+ {
+ shippingOrderId = await db.ShippingOrders.Where(s => s.ShippingNo == shippingNo)
+ .Select(s => (int?)s.Id).FirstOrDefaultAsync(ct);
+ if (shippingOrderId is null)
+ {
+ head.Fail(ApiText.T("出荷指示 '{0}' は登録されていません(ShippingNo)。", shippingNo));
+ }
+ }
+
+ var lines = new List();
+ var failed = head.Failed;
+ foreach (var row in group)
+ {
+ var reader = row == group[0] ? head : new CsvRowReader(table, row, errors);
+ reader.RequiredText("ProductCode");
+ var productId = reader.Reference("ProductCode", null, productIds, "有効な品目");
+ var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
+ if (quantity is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("Quantity(数量)は必須です。"));
+ }
+ if (!reader.Failed)
+ {
+ lines.Add(new PickingRequestLine(productId!.Value, quantity!.Value));
+ }
+ failed |= reader.Failed;
+ }
+ if (failed)
+ {
+ continue;
+ }
+
+ var outcome = await picking.CreateAsync(
+ new PickingOrderCreateRequest(type, workOrderId, shippingOrderId, lines), pickingNo, userId, ct);
+ if (!outcome.Failed && execute)
+ {
+ outcome = await picking.ExecuteAsync(outcome.Value!.Id, userId, ct);
+ }
+ if (outcome.Failed)
+ {
+ FailRow(head, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// 棚卸指示(D-50-10-01)。1行=1件の指示で、作成時点の現在庫(数量>0)が明細になる。
+ /// 後続の実棚数CSVが指示を指すため、棚卸番号は手入力(ST〜は自動採番用として拒否)
+ ///
+ private async Task ImportStocktakesAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var locationIds = await db.Locations.AsNoTracking()
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var stocktakeNo = reader.RequiredText("StocktakeNo", 50);
+ // 空欄は全ロケーション(単票の作成要求と同じ)
+ var locationId = reader.Reference("LocationCode", null, locationIds, "ロケーション");
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await stocktakes.CreateAsync(new StocktakeCreateRequest(locationId), stocktakeNo, userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// 実棚数(D-50-10-02)。棚卸番号が同じ行をまとめて登録し、Finalize=true なら確定(D-50-10-05。
+ /// 差異を棚卸調整として在庫に反映する)まで進める。明細はロット番号とロケーションで指す
+ ///
+ private async Task ImportStocktakeCountsAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var created = 0;
+ foreach (var group in table.Rows.GroupBy(row => table.Value(row, "StocktakeNo")).Select(g => g.ToList()))
+ {
+ var head = new CsvRowReader(table, group[0], errors);
+ var stocktakeNo = head.RequiredText("StocktakeNo");
+ var finalize = head.Bool("Finalize", false);
+ // 前のファイル(棚卸指示CSV)で作った指示も指せるよう、まとまりごとにDBを引く
+ var stocktake = stocktakeNo.Length == 0
+ ? null
+ : await db.Stocktakes.AsNoTracking()
+ .Where(s => s.StocktakeNo == stocktakeNo)
+ .Select(s => new
+ {
+ s.Id,
+ Lines = s.Lines.Select(l => new { l.Id, l.Lot!.LotNumber, l.Location!.Code }).ToList(),
+ })
+ .FirstOrDefaultAsync(ct);
+ if (stocktakeNo.Length > 0 && stocktake is null)
+ {
+ head.Fail(ApiText.T("棚卸 '{0}' は登録されていません(StocktakeNo)。", stocktakeNo));
+ }
+
+ var counts = new List();
+ var failed = head.Failed;
+ foreach (var row in group)
+ {
+ var reader = row == group[0] ? head : new CsvRowReader(table, row, errors);
+ var lotNumber = reader.RequiredText("LotNumber");
+ var locationCode = reader.RequiredText("LocationCode");
+ var counted = reader.NumberOrNull("CountedQuantity", null, 0m);
+ if (counted is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("CountedQuantity(実棚数量)は必須です。"));
+ }
+ if (!reader.Failed && stocktake is not null)
+ {
+ var line = stocktake.Lines.FirstOrDefault(l => l.LotNumber == lotNumber && l.Code == locationCode);
+ if (line is null)
+ {
+ reader.Fail(ApiText.T("棚卸 '{0}' にロット '{1}'・ロケーション '{2}' の明細はありません。",
+ stocktakeNo, lotNumber, locationCode));
+ }
+ else
+ {
+ counts.Add(new StocktakeCountLine(line.Id, counted!.Value));
+ }
+ }
+ failed |= reader.Failed;
+ }
+ if (failed)
+ {
+ continue;
+ }
+
+ var outcome = await stocktakes.RegisterCountsAsync(stocktake!.Id, new StocktakeCountRequest(counts), ct);
+ if (!outcome.Failed && finalize)
+ {
+ outcome = await stocktakes.FinalizeAsync(stocktake.Id, userId, ct);
+ }
+ if (outcome.Failed)
+ {
+ FailRow(head, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+}
diff --git a/src/MesApp.Api/Services/ActualCsvService.Maintenance.cs b/src/MesApp.Api/Services/ActualCsvService.Maintenance.cs
new file mode 100644
index 0000000..c1313b8
--- /dev/null
+++ b/src/MesApp.Api/Services/ActualCsvService.Maintenance.cs
@@ -0,0 +1,247 @@
+using MesApp.Api.Localization;
+using MesApp.Core.Contracts.Masters;
+using MesApp.Core.Contracts.Maintenance;
+using MesApp.Core.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+// 実績CSVのうち保全(E-30-20 保全指示、E-30-30 突発依頼、E-40-30 保全実績)。
+// 指示と実績は MaintenanceOrderService を通す(在庫の引落し・必要スキルの照合・設備の復帰も単票と同じ)
+public sealed partial class ActualCsvService
+{
+ ///
+ /// 保全指示・突発依頼(E-30-20-01 / E-30-30-01)。1行=1件の指示。
+ /// 後続の保全実績CSVが指示を番号で指すため、番号は手入力(MT〜は自動採番用として拒否)
+ ///
+ private async Task ImportMaintenanceOrdersAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var equipmentIds = await db.Equipments.AsNoTracking()
+ .ToDictionaryAsync(e => e.AssetNo, e => e.Id, StringComparer.Ordinal, ct);
+ var toolIds = await db.Tools.AsNoTracking()
+ .ToDictionaryAsync(t => t.Code, t => t.Id, StringComparer.Ordinal, ct);
+ var procedureIds = await db.MaintenanceProcedures.AsNoTracking().Where(p => p.IsActive)
+ .ToDictionaryAsync(p => p.ProcedureNo, p => p.Id, StringComparer.Ordinal, ct);
+ // 計画保全の指示は保全ロールだけが作れる(突発依頼は全員。単票APIの Create と同じ)
+ var canPlan = await IsInGroupAsync(userId, MaintenanceOrderService.PlannedOrderRoles, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var maintenanceNo = reader.RequiredText("MaintenanceNo", 50);
+ var equipmentId = reader.Reference("EquipmentAssetNo", null, equipmentIds, "設備");
+ var toolId = reader.Reference("ToolCode", null, toolIds, "治工具");
+ var fromPlan = reader.Bool("FromPlan", false);
+ // 計画から作る指示は計画保全(依頼区分の省略時も計画として扱う)
+ var requestType = reader.Enum("RequestType",
+ fromPlan ? MaintenanceRequestType.Planned : MaintenanceRequestType.Spot, CsvEnumLabels.MaintenanceRequestTypes);
+ var procedureId = reader.Reference("ProcedureNo", null, procedureIds, "有効な保全手順書");
+ var scheduledDate = reader.DateOrNull("ScheduledDate", null);
+ var note = reader.Text("Note", null, 1000);
+ if (requestType == MaintenanceRequestType.Planned && !canPlan && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("計画保全の指示は保全担当者だけが登録できます(突発依頼は誰でも登録できます)。"));
+ }
+ if (fromPlan && requestType != MaintenanceRequestType.Planned && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("保全計画から作る指示の依頼区分は計画にしてください。"));
+ }
+ var planId = fromPlan && !reader.Failed ? await FindPlanAsync(reader, equipmentId, scheduledDate, ct) : null;
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await maintenanceOrders.CreateAsync(
+ new MaintenanceOrderCreateRequest(equipmentId, toolId, planId, procedureId, scheduledDate, requestType, note),
+ maintenanceNo, userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// 保全計画を指す。計画には番号が無いので、同じ設備・同じ予定日の未指示の計画が1件だけのときに限る
+ /// (0件・2件以上は行エラー。どの計画かを推測で決めない)
+ ///
+ private async Task FindPlanAsync(
+ CsvRowReader reader, int? equipmentId, DateOnly? scheduledDate, CancellationToken ct)
+ {
+ if (equipmentId is null || scheduledDate is null)
+ {
+ reader.Fail(ApiText.T("保全計画から作る指示には EquipmentAssetNo(設備)と ScheduledDate(予定日)が必要です。"));
+ return null;
+ }
+ var plans = await db.MaintenancePlans
+ .Where(p => p.EquipmentId == equipmentId && p.ScheduledDate == scheduledDate
+ && p.Status == MaintenancePlanStatus.Planned)
+ .Select(p => p.Id).ToListAsync(ct);
+ if (plans.Count != 1)
+ {
+ reader.Fail(plans.Count == 0
+ ? ApiText.T("設備と予定日 {0} が一致する未指示の保全計画はありません。", scheduledDate)
+ : ApiText.T("設備と予定日 {0} が一致する未指示の保全計画が {1} 件あります。予定日を分けてください。", scheduledDate, plans.Count));
+ return null;
+ }
+ return plans[0];
+ }
+
+ ///
+ /// 保全計画(E-30-10-01)。1行=1件の計画で、常に新規登録(自然なキーが無いため上書きしない)。
+ /// 計画から保全指示を作るときは、保全指示CSVの FromPlan で設備+予定日から指す
+ ///
+ private async Task ImportMaintenancePlansAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var equipmentIds = await db.Equipments.AsNoTracking().Where(e => e.IsActive)
+ .ToDictionaryAsync(e => e.AssetNo, e => e.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("EquipmentAssetNo");
+ var equipmentId = reader.Reference("EquipmentAssetNo", null, equipmentIds, "有効な設備");
+ var category = reader.Enum("Category", MaintenanceCategory.Periodic, CsvEnumLabels.MaintenanceCategories);
+ var scheduledDate = reader.DateOrNull("ScheduledDate", null);
+ // 省略時は予定日の年(予定日も無ければ必須)
+ var planYear = reader.IntOrNull("PlanYear", null, 2000) ?? scheduledDate?.Year;
+ var cycleDays = reader.IntOrNull("CycleDays", null, 1);
+ var note = reader.Text("Note", null, 1000);
+ if (planYear is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("PlanYear(計画年度)か ScheduledDate(予定日)のどちらかが必要です。"));
+ }
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await maintenanceOrders.CreatePlanAsync(
+ new MaintenancePlanRequest(equipmentId!.Value, category, planYear!.Value, scheduledDate, cycleDays, note),
+ userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// 保全実績(E-40-30-01)。保全番号が同じ行を1件の実績にまとめ、1行に消費部材を1つ書ける。
+ /// 実績の登録で指示は完了するため、1件の指示に実績は1件だけ。実施者は取り込んだユーザー
+ /// (手順書の必要スキルもこのユーザーと照合する)
+ ///
+ private async Task ImportMaintenanceRecordsAsync(
+ CsvTable table, List errors, string userId, CancellationToken ct)
+ {
+ var locationIds = await db.Locations.AsNoTracking()
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var group in table.Rows.GroupBy(row => table.Value(row, "MaintenanceNo")).Select(g => g.ToList()))
+ {
+ var head = new CsvRowReader(table, group[0], errors);
+ var maintenanceNo = head.RequiredText("MaintenanceNo");
+ var startedAt = RequiredDateTime(head, "StartedAt");
+ var endedAt = head.DateTimeOrNull("EndedAt", FactoryOffset);
+ var result = head.Text("Result", null, 2000);
+ var partsUsed = head.Text("PartsUsed", null, 1000);
+ var note = head.Text("Note", null, 1000);
+ var resetToolLife = head.Bool("ResetToolLife", false);
+ // 前のファイル(保全指示CSV)で登録した指示も指せるよう、まとまりごとにDBを引く
+ int? orderId = null;
+ if (maintenanceNo.Length > 0)
+ {
+ orderId = await db.MaintenanceOrders.Where(o => o.OrderNo == maintenanceNo)
+ .Select(o => (int?)o.Id).FirstOrDefaultAsync(ct);
+ if (orderId is null)
+ {
+ head.Fail(ApiText.T("保全指示 '{0}' は登録されていません(MaintenanceNo)。", maintenanceNo));
+ }
+ }
+
+ var parts = new List();
+ var failed = head.Failed;
+ foreach (var row in group)
+ {
+ var reader = row == group[0] ? head : new CsvRowReader(table, row, errors);
+ var part = await ReadMaintenancePartAsync(reader, locationIds, ct);
+ if (part is not null)
+ {
+ parts.Add(part);
+ }
+ failed |= reader.Failed;
+ }
+ if (failed)
+ {
+ continue;
+ }
+
+ var outcome = await maintenanceOrders.AddRecordAsync(orderId!.Value,
+ new MaintenanceRecordRequest(startedAt!.Value, endedAt, partsUsed, result, note, resetToolLife, parts),
+ userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(head, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 行の消費部材(PartLotNumber を空欄にした行は部材なし)
+ private async Task ReadMaintenancePartAsync(
+ CsvRowReader reader, IReadOnlyDictionary locationIds, CancellationToken ct)
+ {
+ var lotNumber = reader.Text("PartLotNumber", null);
+ var locationId = reader.Reference("PartLocationCode", null, locationIds, "ロケーション");
+ var quantity = reader.NumberOrNull("PartQuantity", null, 0.0001m);
+ var note = reader.Text("PartNote", null, 500);
+ if (lotNumber is null)
+ {
+ if ((locationId is not null || quantity is not null) && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("消費部材を書く行には PartLotNumber(部材のロット番号)が必要です。"));
+ }
+ return null;
+ }
+ if ((locationId is null || quantity is null) && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("消費部材には PartLocationCode(払出元ロケーション)と PartQuantity(数量)が必要です。"));
+ }
+ // 受入CSVなど前のファイルで作ったロットも指せるよう、DBを引く
+ var lotId = await db.Lots.Where(l => l.LotNumber == lotNumber)
+ .Select(l => (int?)l.Id).FirstOrDefaultAsync(ct);
+ if (lotId is null)
+ {
+ reader.Fail(ApiText.T("ロット '{0}' は登録されていません(PartLotNumber)。", lotNumber));
+ }
+ return reader.Failed ? null : new MaintenanceRecordPartRequest(lotId!.Value, locationId!.Value, quantity!.Value, note);
+ }
+
+ /// 取り込んだユーザーがロールグループに入っているか(単票APIの User.IsInRole の代わり)
+ private async Task IsInGroupAsync(string? userId, string roleGroup, CancellationToken ct)
+ {
+ if (userId is null)
+ {
+ return false;
+ }
+ var roles = roleGroup.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return await (from userRole in db.UserRoles
+ join role in db.Roles on userRole.RoleId equals role.Id
+ where userRole.UserId == userId && roles.Contains(role.Name!)
+ select userRole.UserId).AnyAsync(ct);
+ }
+}
diff --git a/src/MesApp.Api/Services/ActualCsvService.Production.cs b/src/MesApp.Api/Services/ActualCsvService.Production.cs
new file mode 100644
index 0000000..cd3afd9
--- /dev/null
+++ b/src/MesApp.Api/Services/ActualCsvService.Production.cs
@@ -0,0 +1,87 @@
+using MesApp.Api.Localization;
+using MesApp.Core.Contracts.Masters;
+using MesApp.Core.Contracts.Production;
+using MesApp.Core.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+// 実績CSVのうち製造指図(A-20-10-01 / B-10-10-01。登録・承認・展開は ManufacturingOrderService)
+public sealed partial class ActualCsvService
+{
+ ///
+ /// 製造指図(A-20-10-01)。1行=1指図で、列の指定に応じて承認(A-20-20-01)・工程展開(B-10-10-01)まで進める。
+ /// 後続の実績CSVは作業指示を「指図番号+工程順序」で指すため、指図番号はここで確定させる
+ ///
+ private async Task ImportManufacturingOrdersAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var productIds = await db.Products.AsNoTracking().Where(p => p.IsActive)
+ .ToDictionaryAsync(p => p.Code, p => p.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ // 作業指示番号は「指図番号-工程順序2桁」になるため、その分を残す
+ var orderNo = reader.Text("OrderNo", null, 46);
+ reader.RequiredText("ProductCode");
+ var productId = reader.Reference("ProductCode", null, productIds, "有効な品目");
+ var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
+ var dueDate = reader.DateOrNull("DueDate", null);
+ var orderType = reader.Enum("OrderType", ManufacturingOrderType.Normal, CsvEnumLabels.OrderTypes);
+ var sourceOrderNo = reader.Text("SourceOrderNo", null);
+ var note = reader.Text("Note", null, 1000);
+ var approve = reader.Bool("Approve", false);
+ var expand = reader.Bool("Expand", false);
+ var outputLotNumber = reader.Text("OutputLotNumber", null, 60);
+ if (quantity is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("Quantity(指図数量)は必須です。"));
+ }
+ if (expand && !approve)
+ {
+ reader.Fail(ApiText.T("工程展開は承認済みの指図だけに行えます。Expand を true にするときは Approve も true にしてください。"));
+ }
+ if (outputLotNumber is not null && !expand)
+ {
+ reader.Fail(ApiText.T("OutputLotNumber(産出ロット番号)は工程展開するとき(Expand=true)だけ指定できます。"));
+ }
+
+ int? sourceOrderId = null;
+ if (sourceOrderNo is not null)
+ {
+ // 前の行で登録した指図も保存済みなので、DBを引けば同じファイル内の指図を指せる
+ sourceOrderId = await db.ManufacturingOrders.Where(o => o.OrderNo == sourceOrderNo)
+ .Select(o => (int?)o.Id).FirstOrDefaultAsync(ct);
+ if (sourceOrderId is null)
+ {
+ reader.Fail(ApiText.T("元指図 '{0}' は登録されていません(SourceOrderNo)。", sourceOrderNo));
+ }
+ }
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await orders.CreateAsync(
+ new CreateManufacturingOrderRequest(productId!.Value, quantity!.Value, dueDate, orderType, sourceOrderId, note),
+ orderNo, userId, ct);
+ if (outcome.Order is { } order && approve)
+ {
+ outcome = await orders.ApproveAsync(order, userId, ct);
+ if (outcome.Order is not null && expand)
+ {
+ outcome = await orders.ExpandAsync(order, outputLotNumber, ct);
+ }
+ }
+ if (outcome.Error is not null)
+ {
+ FailRow(reader, outcome.Error);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+}
diff --git a/src/MesApp.Api/Services/ActualCsvService.Quality.cs b/src/MesApp.Api/Services/ActualCsvService.Quality.cs
new file mode 100644
index 0000000..25d58b3
--- /dev/null
+++ b/src/MesApp.Api/Services/ActualCsvService.Quality.cs
@@ -0,0 +1,395 @@
+using MesApp.Api.Localization;
+using MesApp.Core.Contracts.Execution;
+using MesApp.Core.Contracts.Inventory;
+using MesApp.Core.Contracts.Maintenance;
+using MesApp.Core.Contracts.Masters;
+using MesApp.Core.Contracts.Quality;
+using MesApp.Core.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+// 実績CSVのうち検査・作業時間・トラブル・設備稼働記録(C-20 / B-30-30-02 / B-60-10 / B-40-20)
+public sealed partial class ActualCsvService
+{
+ ///
+ /// 検査。InspectionKey が同じ行を1件の検査指示にまとめ、指示の発行(C-20-10-02)→実績の登録(C-20-10-03)
+ /// →(Judge=true なら)総合判定(C-20-10-04)まで進める。1行=1項目×1サンプルの実績
+ ///
+ private async Task ImportInspectionsAsync(
+ CsvTable table, List errors, string userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var itemIds = await db.InspectionItems.AsNoTracking()
+ .ToDictionaryAsync(i => i.Code, i => i.Id, StringComparer.Ordinal, ct);
+ var deviceIds = await db.InspectionDevices.AsNoTracking()
+ .ToDictionaryAsync(d => d.Code, d => d.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var group in table.Rows.GroupBy(row => table.Value(row, "InspectionKey")).Select(g => g.ToList()))
+ {
+ var head = new CsvRowReader(table, group[0], errors);
+ head.RequiredText("InspectionKey");
+ head.RequiredText("Type");
+ var type = head.Enum("Type", InspectionOrderType.Receiving, CsvEnumLabels.InspectionOrderTypes);
+ var lotNumber = head.Text("LotNumber", null);
+ var workOrderId = type == InspectionOrderType.InProcess ? ResolveOptionalWorkOrder(head, workOrders) : null;
+ var judge = head.Bool("Judge", false);
+ var grade = head.Text("Grade", null, 50);
+ var note = head.Text("Note", null, 1000);
+ if (type == InspectionOrderType.InProcess && workOrderId is null && !head.Failed)
+ {
+ head.Fail(ApiText.T("工程内検査には OrderNo(指図番号)と Sequence(工程順序)が必要です。"));
+ }
+ int? lotId = null;
+ if (type != InspectionOrderType.InProcess)
+ {
+ if (lotNumber is null)
+ {
+ head.Fail(ApiText.T("工程内検査以外は LotNumber(対象ロット番号)が必要です。"));
+ }
+ else
+ {
+ // 受入・生産実績の取込で前のファイルが作ったロットも指せるよう、DBを引く
+ lotId = await db.Lots.Where(l => l.LotNumber == lotNumber)
+ .Select(l => (int?)l.Id).FirstOrDefaultAsync(ct);
+ if (lotId is null)
+ {
+ head.Fail(ApiText.T("ロット '{0}' は登録されていません(LotNumber)。", lotNumber));
+ }
+ }
+ }
+
+ var results = new List();
+ var failed = head.Failed;
+ foreach (var row in group)
+ {
+ var reader = row == group[0] ? head : new CsvRowReader(table, row, errors);
+ reader.RequiredText("ItemCode");
+ var itemId = reader.Reference("ItemCode", null, itemIds, "検査項目");
+ var sampleNo = reader.IntOrNull("SampleNo", null, 1);
+ var measured = reader.NumberOrNull("MeasuredValue", null);
+ var text = reader.Text("TextValue", null, 500);
+ InspectionJudgment? judgment = table.Value(row, "Judgment") is null
+ ? null
+ : reader.Enum("Judgment", InspectionJudgment.Pass, CsvEnumLabels.InspectionJudgments);
+ var deviceId = reader.Reference("DeviceCode", null, deviceIds, "検査機");
+ if (reader.Failed)
+ {
+ failed = true;
+ continue;
+ }
+ results.Add(new InspectionResultRequest(itemId!.Value, sampleNo, measured, text, judgment, deviceId));
+ }
+ if (failed)
+ {
+ continue;
+ }
+
+ var outcome = await inspections.CreateAsync(new InspectionOrderCreateRequest(
+ type, lotId, workOrderId, [.. results.Select(r => r.InspectionItemId).Distinct()], note), userId, ct);
+ if (outcome.Value is { } order)
+ {
+ outcome = await inspections.AddResultsAsync(order.Id, results, userId, ct);
+ if (!outcome.Failed && judge)
+ {
+ outcome = await inspections.JudgeAsync(order.Id, grade, userId, ct);
+ }
+ }
+ if (outcome.Failed)
+ {
+ FailRow(head, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 作業時間。1行=1記録(記録者は取り込んだユーザー)
+ private async Task ImportWorkTimeRecordsAsync(
+ CsvTable table, List errors, string userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("Type");
+ var type = reader.Enum("Type", WorkTimeType.Direct, CsvEnumLabels.WorkTimeTypes);
+ var category = reader.Text("IndirectCategory", null, 100);
+ var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
+ var startedAt = RequiredDateTime(reader, "StartedAt");
+ var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
+ var note = reader.Text("Note", null, 500);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await reports.AddWorkTimeAsync(
+ new WorkTimeRequest(type, category, workOrderId, startedAt!.Value, endedAt, note), userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 製造トラブル報告。1行=1報告(報告者は取り込んだユーザー)
+ private async Task ImportTroubleReportsAsync(
+ CsvTable table, List errors, string userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var equipmentIds = await db.Equipments.AsNoTracking()
+ .ToDictionaryAsync(e => e.AssetNo, e => e.Id, StringComparer.Ordinal, ct);
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var occurredAt = RequiredDateTime(reader, "OccurredAt");
+ reader.RequiredText("Category");
+ var category = reader.Enum("Category", TroubleCategory.Quality, CsvEnumLabels.TroubleCategories);
+ var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
+ var equipmentId = reader.Reference("EquipmentAssetNo", null, equipmentIds, "設備");
+ var content = reader.RequiredText("Content", 2000);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await reports.AddTroubleReportAsync(
+ new TroubleReportRequest(occurredAt!.Value, category, workOrderId, equipmentId, content), userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 設備稼働記録。1行=1区間(記録者は取り込んだユーザー)
+ private async Task ImportEquipmentLogsAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var equipmentIds = await db.Equipments.AsNoTracking()
+ .ToDictionaryAsync(e => e.AssetNo, e => e.Id, StringComparer.Ordinal, ct);
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("EquipmentAssetNo");
+ var equipmentId = reader.Reference("EquipmentAssetNo", null, equipmentIds, "設備");
+ reader.RequiredText("Status");
+ var status = reader.Enum("Status", EquipmentLogStatus.Running, CsvEnumLabels.EquipmentLogStatuses);
+ var startedAt = RequiredDateTime(reader, "StartedAt");
+ var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
+ var stopCause = reader.Text("StopCause", null, 500);
+ var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
+ var note = reader.Text("Note", null, 500);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await reports.AddEquipmentLogAsync(
+ new EquipmentLogRequest(equipmentId!.Value, status, startedAt!.Value, endedAt, stopCause, note, workOrderId),
+ userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// 不適合(B-40-30-01〜02 記録、C-30-20 対応指示・実績・承認)。1行=1件の不適合で、
+ /// 不適合を ReportNo(未登録なら新規起票。起票は番号を手入力し NC〜 は拒否)か、
+ /// ReportNo を空欄にして LotNumber(そのロットの未完了の不適合が1件だけのとき)で指し、
+ /// Action→ActionRecord→Approve の列があればその順に進める。
+ /// 検査の不合格で自動起票された不適合は番号が取り込むまで分からないので、ロット番号で指す
+ ///
+ private async Task ImportNonconformancesAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ // 対応指示・承認は品質管理の権限(記録と対応実績は誰でも。単票APIと同じ)
+ var canManage = await IsInGroupAsync(userId, MesRoleGroups.QualityManage, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var reportNo = reader.Text("ReportNo", null, 50);
+ var lotNumber = reader.Text("LotNumber", null);
+ var source = reader.Enum("Source", NonconformanceSource.Production, CsvEnumLabels.NonconformanceSources);
+ var content = reader.Text("Content", null, 2000);
+ var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
+ var causeCategory = reader.Text("CauseCategory", null, 100);
+ var causeDetail = reader.Text("CauseDetail", null, 2000);
+ var action = reader.Text("Action", null) is null
+ ? (NonconformanceAction?)null
+ : reader.Enum("Action", NonconformanceAction.Hold, CsvEnumLabels.NonconformanceActions);
+ var instruction = reader.Text("ActionInstruction", null, 2000);
+ var actionRecord = reader.Text("ActionRecord", null, 2000);
+ var approve = reader.Bool("Approve", false);
+ if ((action is not null || approve) && !canManage && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("不適合の対応指示・承認は品質管理の担当者だけが登録できます。"));
+ }
+ if (reportNo is null && lotNumber is null && !reader.Failed)
+ {
+ reader.Fail(ApiText.T("ReportNo(不適合番号)か LotNumber(ロット番号)のどちらかが必要です。"));
+ }
+ var lotId = lotNumber is null || reader.Failed ? null : await LotIdAsync(reader, lotNumber, "LotNumber", ct);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ int? reportId;
+ if (reportNo is not null)
+ {
+ reportId = await db.NonconformanceReports.Where(n => n.ReportNo == reportNo)
+ .Select(n => (int?)n.Id).FirstOrDefaultAsync(ct);
+ if (reportId is null)
+ {
+ if (content is null)
+ {
+ reader.Fail(ApiText.T("不適合を起票する行には Content(内容)が必要です。"));
+ continue;
+ }
+ var createdReport = await nonconformances.CreateAsync(
+ new NonconformanceCreateRequest(source, lotId, workOrderId, null, content, causeCategory, causeDetail),
+ reportNo, userId, ct);
+ if (createdReport.Failed)
+ {
+ FailRow(reader, createdReport.Error!);
+ continue;
+ }
+ reportId = createdReport.Value!.Id;
+ }
+ }
+ else
+ {
+ // 番号の分からない不適合(検査の不合格で自動起票されたもの等)は、ロットで1件に決まるときだけ指せる
+ var open = await db.NonconformanceReports
+ .Where(n => n.LotId == lotId && n.Status != NonconformanceStatus.Closed)
+ .Select(n => n.Id).ToListAsync(ct);
+ if (open.Count != 1)
+ {
+ reader.Fail(open.Count == 0
+ ? ApiText.T("ロット '{0}' に未完了の不適合はありません。", lotNumber)
+ : ApiText.T("ロット '{0}' に未完了の不適合が {1} 件あります。ReportNo で指定してください。", lotNumber, open.Count));
+ continue;
+ }
+ reportId = open[0];
+ }
+
+ // 失敗した操作の理由(業務サービスは失敗のときだけ Error を返す)
+ string? error = null;
+ if (action is not null)
+ {
+ error = (await nonconformances.InstructAsync(
+ reportId.Value, new NonconformanceActionRequest(action.Value, instruction), userId, ct)).Error;
+ }
+ if (error is null && actionRecord is not null)
+ {
+ error = (await nonconformances.RecordActionAsync(
+ reportId.Value, new NonconformanceActionRecordRequest(actionRecord), userId, ct)).Error;
+ }
+ if (error is null && approve)
+ {
+ error = (await nonconformances.ApproveAsync(reportId.Value, userId, ct)).Error;
+ }
+ if (error is not null)
+ {
+ FailRow(reader, error);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// サンプル品の保管(D-40-50-01)。1行=1件のサンプルで、未登録のサンプル番号なら採取(在庫から抜く)、
+ /// Close を書けば払出・廃棄で保管を終える。登録済みのサンプル番号の行は保管の終了だけを行う
+ ///
+ private async Task ImportSampleStoragesAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var locationIds = await db.Locations.AsNoTracking()
+ .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var sampleNo = reader.RequiredText("SampleNo", 50);
+ var lotNumber = reader.Text("LotNumber", null);
+ var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
+ var locationId = reader.Reference("StorageLocationCode", null, locationIds, "ロケーション");
+ var retainUntil = reader.DateOrNull("RetainUntil", null);
+ var note = reader.Text("Note", null, 500);
+ var close = reader.Text("Close", null) is null
+ ? (SampleStorageStatus?)null
+ : reader.Enum("Close", SampleStorageStatus.Consumed, CsvEnumLabels.SampleCloseStatuses);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var sampleId = await db.SampleStorages.Where(x => x.SampleNo == sampleNo)
+ .Select(x => (int?)x.Id).FirstOrDefaultAsync(ct);
+ if (sampleId is null)
+ {
+ if (lotNumber is null || quantity is null || locationId is null)
+ {
+ reader.Fail(ApiText.T("サンプルを採取する行には LotNumber・Quantity・StorageLocationCode が必要です。"));
+ continue;
+ }
+ var lotId = await LotIdAsync(reader, lotNumber, "LotNumber", ct);
+ if (reader.Failed)
+ {
+ continue;
+ }
+ var collected = await samples.CollectAsync(
+ new SampleCollectRequest(lotId!.Value, locationId.Value, quantity.Value, retainUntil, null, note),
+ sampleNo, userId, ct);
+ if (collected.Failed)
+ {
+ FailRow(reader, collected.Error!);
+ continue;
+ }
+ sampleId = collected.Value!.Id;
+ }
+ else if (close is null)
+ {
+ reader.Fail(ApiText.T("サンプル番号 '{0}' は既に存在します。", sampleNo));
+ continue;
+ }
+
+ if (close is not null)
+ {
+ var closed = await samples.CloseAsync(sampleId.Value, new SampleCloseRequest(close.Value, note), userId, ct);
+ if (closed.Failed)
+ {
+ FailRow(reader, closed.Error!);
+ continue;
+ }
+ }
+ created++;
+ }
+ return created;
+ }
+}
diff --git a/src/MesApp.Api/Services/ActualCsvService.Tools.cs b/src/MesApp.Api/Services/ActualCsvService.Tools.cs
new file mode 100644
index 0000000..b796012
--- /dev/null
+++ b/src/MesApp.Api/Services/ActualCsvService.Tools.cs
@@ -0,0 +1,126 @@
+using MesApp.Core.Contracts.Maintenance;
+using MesApp.Core.Contracts.Masters;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+// 実績CSVのうち治工具と検査機(E-60-20-01 利用実績、B-20-30 引当・払出・返却、C-20-50-03 校正)。
+// 利用実績は ShopFloorReportService、引当は ToolIssueService、校正は InspectionDeviceService を通す
+public sealed partial class ActualCsvService
+{
+ /// 治工具の利用実績(E-60-20-01)。1行=1記録。寿命の累計に入る
+ private async Task ImportToolUsagesAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var toolIds = await db.Tools.AsNoTracking().Where(t => t.IsActive)
+ .ToDictionaryAsync(t => t.Code, t => t.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("ToolCode");
+ var toolId = reader.Reference("ToolCode", null, toolIds, "有効な治工具");
+ var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
+ var usageCount = reader.IntOrNull("UsageCount", null, 0) ?? 0;
+ var usageHours = reader.NumberOrNull("UsageHours", null, 0m);
+ // 空欄なら取り込んだ時刻(寿命の累計はメンテでリセットした時刻より後の記録だけを数える)
+ var recordedAt = reader.Text("RecordedAt", null) is null ? null : reader.DateTimeOrNull("RecordedAt", FactoryOffset);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await reports.AddToolUsageAsync(
+ new ToolUsageRequest(toolId!.Value, workOrderId, usageCount, usageHours), recordedAt, userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ ///
+ /// 治工具の引当(B-20-30-01)。1行=1件の引当で、Issue=true なら払出・受領確認(受領者は取り込んだユーザー)、
+ /// Return=true なら返却まで進める。引当には番号が無いので、後のファイルからは指さない
+ ///
+ private async Task ImportToolIssuesAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var workOrders = await WorkOrderKeysAsync(ct);
+ var toolIds = await db.Tools.AsNoTracking()
+ .ToDictionaryAsync(t => t.Code, t => t.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("ToolCode");
+ var toolId = reader.Reference("ToolCode", null, toolIds, "治工具");
+ var workOrderId = ResolveWorkOrder(reader, workOrders);
+ var issue = reader.Bool("Issue", false);
+ var returned = reader.Bool("Return", false);
+ var note = reader.Text("Note", null, 500);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ // 引当・払出のどちらでも ToolIssuePolicy が使えるかを判定する(単票と同じ)
+ var outcome = await toolIssues.AllocateAsync(new ToolAllocateRequest(toolId!.Value, workOrderId!.Value, note), userId, ct);
+ if (!outcome.Failed && issue)
+ {
+ outcome = await toolIssues.IssueAsync(outcome.Value!.Id, new ToolIssueReceiveRequest(null), userId, ct);
+ }
+ if (!outcome.Failed && returned)
+ {
+ outcome = await toolIssues.ReturnAsync(outcome.Value!.Id, new ToolIssueCloseRequest(null), userId, ct);
+ }
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+
+ /// 検査機・測定器の校正(C-20-50-03)。1行=1回の校正。検査機の最終校正日と次回期限が更新される
+ private async Task ImportCalibrationsAsync(
+ CsvTable table, List errors, string? userId, CancellationToken ct)
+ {
+ var deviceIds = await db.InspectionDevices.AsNoTracking()
+ .ToDictionaryAsync(d => d.Code, d => d.Id, StringComparer.Ordinal, ct);
+
+ var created = 0;
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ reader.RequiredText("DeviceCode");
+ var deviceId = reader.Reference("DeviceCode", null, deviceIds, "検査機");
+ reader.RequiredText("CalibratedOn");
+ var calibratedOn = reader.DateOrNull("CalibratedOn", null);
+ var nextDueOn = reader.DateOrNull("NextDueOn", null);
+ var result = reader.Text("Result", null, 500);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ var outcome = await devices.AddCalibrationAsync(deviceId!.Value,
+ new InspectionDeviceCalibrationRequest(calibratedOn!.Value, nextDueOn, result), userId, ct);
+ if (outcome.Failed)
+ {
+ FailRow(reader, outcome.Error!);
+ continue;
+ }
+ created++;
+ }
+ return created;
+ }
+}
diff --git a/src/MesApp.Api/Services/ActualCsvService.cs b/src/MesApp.Api/Services/ActualCsvService.cs
index 8c58dec..98f6650 100644
--- a/src/MesApp.Api/Services/ActualCsvService.cs
+++ b/src/MesApp.Api/Services/ActualCsvService.cs
@@ -1,12 +1,6 @@
using MesApp.Api.Localization;
-using System.Globalization;
using MesApp.Core.Abstractions;
-using MesApp.Core.Contracts.Execution;
-using MesApp.Core.Contracts.Inventory;
-using MesApp.Core.Contracts.Maintenance;
using MesApp.Core.Contracts.Masters;
-using MesApp.Core.Contracts.Production;
-using MesApp.Core.Contracts.Quality;
using MesApp.Core.Entities;
using MesApp.Infrastructure;
using Microsoft.EntityFrameworkCore;
@@ -36,6 +30,15 @@ public sealed partial class ActualCsvService(
ShopFloorReportService reports,
ShippingService shipping,
ShipmentJudgmentService judgments,
+ MaintenanceOrderService maintenanceOrders,
+ ToolIssueService toolIssues,
+ InspectionDeviceService devices,
+ LotOperationService lotOperations,
+ TransferOrderService transfers,
+ PickingService picking,
+ StocktakeService stocktakes,
+ NonconformanceService nonconformances,
+ SampleStorageService samples,
IBusinessDateService businessDate,
IAuditLogger auditLogger)
{
@@ -76,6 +79,19 @@ private async Task ImportOneAsync(
ActualCsvKinds.ShippingOrders => await ImportShippingOrdersAsync(table, errors, userId, ct),
ActualCsvKinds.ShipmentJudgments => await ImportShipmentJudgmentsAsync(table, errors, userId!, ct),
ActualCsvKinds.Shipments => await ImportShipmentsAsync(table, errors, userId, ct),
+ ActualCsvKinds.MaintenancePlans => await ImportMaintenancePlansAsync(table, errors, userId, ct),
+ ActualCsvKinds.MaintenanceOrders => await ImportMaintenanceOrdersAsync(table, errors, userId, ct),
+ ActualCsvKinds.MaintenanceRecords => await ImportMaintenanceRecordsAsync(table, errors, userId!, ct),
+ ActualCsvKinds.ToolUsages => await ImportToolUsagesAsync(table, errors, userId, ct),
+ ActualCsvKinds.ToolIssues => await ImportToolIssuesAsync(table, errors, userId, ct),
+ ActualCsvKinds.Calibrations => await ImportCalibrationsAsync(table, errors, userId, ct),
+ ActualCsvKinds.InventoryOperations => await ImportInventoryOperationsAsync(table, errors, userId, ct),
+ ActualCsvKinds.TransferOrders => await ImportTransferOrdersAsync(table, errors, userId, ct),
+ ActualCsvKinds.PickingOrders => await ImportPickingOrdersAsync(table, errors, userId, ct),
+ ActualCsvKinds.Stocktakes => await ImportStocktakesAsync(table, errors, userId, ct),
+ ActualCsvKinds.StocktakeCounts => await ImportStocktakeCountsAsync(table, errors, userId, ct),
+ ActualCsvKinds.Nonconformances => await ImportNonconformancesAsync(table, errors, userId, ct),
+ ActualCsvKinds.SampleStorages => await ImportSampleStoragesAsync(table, errors, userId, ct),
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
if (errors.Count == 0)
@@ -86,594 +102,6 @@ await auditLogger.LogAsync("Actual", "CsvImport", kind.Info.Kind, null,
return new CsvFileImportCount(created, 0);
}
- /// 受入(D-10-10-02)。1行=1ロットの受入登録
- private async Task ImportReceivingAsync(
- CsvTable table, List errors, string? userId, CancellationToken ct)
- {
- // 無効なマスタは候補に入れない(単票APIも無効な品目・ロケーションへの受入を拒否する)
- var productIds = await db.Products.AsNoTracking().Where(p => p.IsActive)
- .ToDictionaryAsync(p => p.Code, p => p.Id, StringComparer.Ordinal, ct);
- var locationIds = await db.Locations.AsNoTracking().Where(l => l.IsActive)
- .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
-
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- reader.RequiredText("ProductCode");
- var productId = reader.Reference("ProductCode", null, productIds, "有効な品目");
- var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
- reader.RequiredText("LocationCode");
- var locationId = reader.Reference("LocationCode", null, locationIds, "有効なロケーション");
- var lotNumber = reader.Text("LotNumber", null, 60);
- var expiresOn = reader.DateOrNull("ExpiresOn", null);
- var note = reader.Text("Note", null, 500);
- if (quantity is null && !reader.Failed)
- {
- reader.Fail(ApiText.T("Quantity(受入数量)は必須です。"));
- }
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await receiving.ReceiveAsync(
- new ReceivingRequest(productId!.Value, quantity!.Value, locationId!.Value, lotNumber, expiresOn, note),
- userId, ct);
- if (outcome.Error is not null)
- {
- FailRow(reader, outcome.Error);
- continue;
- }
- created++;
- }
- return created;
- }
-
- ///
- /// 製造指図(A-20-10-01)。1行=1指図で、列の指定に応じて承認(A-20-20-01)・工程展開(B-10-10-01)まで進める。
- /// 後続の実績CSVは作業指示を「指図番号+工程順序」で指すため、指図番号はここで確定させる
- ///
- private async Task ImportManufacturingOrdersAsync(
- CsvTable table, List errors, string? userId, CancellationToken ct)
- {
- var productIds = await db.Products.AsNoTracking().Where(p => p.IsActive)
- .ToDictionaryAsync(p => p.Code, p => p.Id, StringComparer.Ordinal, ct);
-
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- // 作業指示番号は「指図番号-工程順序2桁」になるため、その分を残す
- var orderNo = reader.Text("OrderNo", null, 46);
- reader.RequiredText("ProductCode");
- var productId = reader.Reference("ProductCode", null, productIds, "有効な品目");
- var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
- var dueDate = reader.DateOrNull("DueDate", null);
- var orderType = reader.Enum("OrderType", ManufacturingOrderType.Normal, CsvEnumLabels.OrderTypes);
- var sourceOrderNo = reader.Text("SourceOrderNo", null);
- var note = reader.Text("Note", null, 1000);
- var approve = reader.Bool("Approve", false);
- var expand = reader.Bool("Expand", false);
- var outputLotNumber = reader.Text("OutputLotNumber", null, 60);
- if (quantity is null && !reader.Failed)
- {
- reader.Fail(ApiText.T("Quantity(指図数量)は必須です。"));
- }
- if (expand && !approve)
- {
- reader.Fail(ApiText.T("工程展開は承認済みの指図だけに行えます。Expand を true にするときは Approve も true にしてください。"));
- }
- if (outputLotNumber is not null && !expand)
- {
- reader.Fail(ApiText.T("OutputLotNumber(産出ロット番号)は工程展開するとき(Expand=true)だけ指定できます。"));
- }
-
- int? sourceOrderId = null;
- if (sourceOrderNo is not null)
- {
- // 前の行で登録した指図も保存済みなので、DBを引けば同じファイル内の指図を指せる
- sourceOrderId = await db.ManufacturingOrders.Where(o => o.OrderNo == sourceOrderNo)
- .Select(o => (int?)o.Id).FirstOrDefaultAsync(ct);
- if (sourceOrderId is null)
- {
- reader.Fail(ApiText.T("元指図 '{0}' は登録されていません(SourceOrderNo)。", sourceOrderNo));
- }
- }
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await orders.CreateAsync(
- new CreateManufacturingOrderRequest(productId!.Value, quantity!.Value, dueDate, orderType, sourceOrderId, note),
- orderNo, userId, ct);
- if (outcome.Order is { } order && approve)
- {
- outcome = await orders.ApproveAsync(order, userId, ct);
- if (outcome.Order is not null && expand)
- {
- outcome = await orders.ExpandAsync(order, outputLotNumber, ct);
- }
- }
- if (outcome.Error is not null)
- {
- FailRow(reader, outcome.Error);
- continue;
- }
- created++;
- }
- return created;
- }
-
- // ---- 作業指示の実行記録(B-20-50 / B-30-10 / B-30-20 / B-40-10 / B-30-30-04)----
-
- /// 段取り実績。1行=1記録
- private async Task ImportSetupRecordsAsync(
- CsvTable table, List errors, string userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- var workOrderId = ResolveWorkOrder(reader, workOrders);
- reader.RequiredText("Type");
- var type = reader.Enum("Type", SetupType.Pre, CsvEnumLabels.SetupTypes);
- var startedAt = RequiredDateTime(reader, "StartedAt");
- var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
- var note = reader.Text("AbnormalityNote", null, 1000);
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await execution.AddSetupRecordAsync(
- workOrderId!.Value, new SetupRecordRequest(type, startedAt!.Value, endedAt, note), userId, ct);
- if (outcome.Failed)
- {
- FailRow(reader, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- ///
- /// チェックリスト実施。指図番号・工程順序・チェックリストコードが同じ行を1回の実施としてまとめる
- /// (マスタCSVのチェックリストと同じく、1行=1項目)
- ///
- private async Task ImportChecklistRecordsAsync(
- CsvTable table, List errors, string userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var checklists = await db.Checklists.AsNoTracking().Include(c => c.Items)
- .ToDictionaryAsync(c => c.Code, StringComparer.Ordinal, ct);
-
- var groups = table.Rows
- .GroupBy(row => (table.Value(row, "OrderNo"), table.Value(row, "Sequence"), table.Value(row, "ChecklistCode")))
- .Select(g => g.ToList())
- .ToList();
-
- var created = 0;
- foreach (var group in groups)
- {
- var head = new CsvRowReader(table, group[0], errors);
- var workOrderId = ResolveWorkOrder(head, workOrders);
- var code = head.RequiredText("ChecklistCode");
- if (head.Failed)
- {
- continue;
- }
- if (!checklists.TryGetValue(code, out var checklist))
- {
- head.Fail(ApiText.T("チェックリスト '{0}' は登録されていません(ChecklistCode)。", code));
- continue;
- }
-
- var results = new List();
- var failed = false;
- foreach (var row in group)
- {
- var reader = new CsvRowReader(table, row, errors);
- var itemSequence = reader.IntOrNull("ItemSequence", null, 1);
- var isChecked = reader.Bool("IsChecked", true);
- var note = reader.Text("Note", null, 500);
- if (itemSequence is null && !reader.Failed)
- {
- reader.Fail(ApiText.T("ItemSequence(項目の表示順)は必須です。"));
- }
- var item = checklist.Items.FirstOrDefault(i => i.Sequence == itemSequence);
- if (itemSequence is not null && item is null)
- {
- reader.Fail(ApiText.T("チェックリスト '{0}' に表示順 {1} の項目はありません。", code, itemSequence));
- }
- else if (item is not null && results.Any(r => r.ChecklistItemId == item.Id))
- {
- reader.Fail(ApiText.T("チェックリスト '{0}' の表示順 {1} が重複しています。", code, itemSequence));
- }
- if (reader.Failed)
- {
- failed = true;
- continue;
- }
- results.Add(new ChecklistResultRequest(item!.Id, isChecked, note));
- }
- if (failed)
- {
- continue;
- }
-
- var outcome = await execution.AddChecklistRecordAsync(
- workOrderId!.Value, new ChecklistRecordRequest(checklist.Id, results), userId, ct);
- if (outcome.Failed)
- {
- FailRow(head, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- /// 部材投入。1行=1ロットの投入(在庫の払出と同時)
- private async Task ImportConsumptionsAsync(
- CsvTable table, List errors, string? userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var locationIds = await db.Locations.AsNoTracking()
- .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
-
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- var workOrderId = ResolveWorkOrder(reader, workOrders);
- var lotNumber = reader.RequiredText("LotNumber");
- reader.RequiredText("LocationCode");
- var locationId = reader.Reference("LocationCode", null, locationIds, "ロケーション");
- var quantity = reader.NumberOrNull("Quantity", null, 0.000001m);
- var substituteReason = reader.Text("SubstituteReason", null, 500);
- if (quantity is null && !reader.Failed)
- {
- reader.Fail(ApiText.T("Quantity(投入数量)は必須です。"));
- }
- // 前のファイル(受入・工程展開)や前の行で作られたロットも指せるよう、行ごとにDBを引く
- int? lotId = null;
- if (lotNumber.Length > 0)
- {
- lotId = await db.Lots.Where(l => l.LotNumber == lotNumber)
- .Select(l => (int?)l.Id).FirstOrDefaultAsync(ct);
- if (lotId is null)
- {
- reader.Fail(ApiText.T("ロット '{0}' は登録されていません(LotNumber)。", lotNumber));
- }
- }
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await execution.AddConsumptionAsync(workOrderId!.Value,
- new ConsumptionRequest(lotId!.Value, locationId!.Value, quantity!.Value, substituteReason), userId, ct);
- if (outcome.Failed)
- {
- FailRow(reader, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- /// 生産実績。1行=1回の実績報告(分割報告は行を分ける)
- private async Task ImportProductionRecordsAsync(
- CsvTable table, List errors, string userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var locationIds = await db.Locations.AsNoTracking()
- .ToDictionaryAsync(l => l.Code, l => l.Id, StringComparer.Ordinal, ct);
- var defectReasonIds = await db.DefectReasons.AsNoTracking()
- .ToDictionaryAsync(r => r.Code, r => r.Id, StringComparer.Ordinal, ct);
-
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- var workOrderId = ResolveWorkOrder(reader, workOrders);
- reader.RequiredText("GoodQuantity");
- var good = reader.Number("GoodQuantity", 0m, 0);
- var defect = reader.Number("DefectQuantity", 0m, 0);
- var scrap = reader.Number("ScrapQuantity", 0m, 0);
- var rework = reader.Number("ReworkQuantity", 0m, 0);
- var startedAt = RequiredDateTime(reader, "StartedAt");
- var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
- var locationId = reader.Reference("OutputLocationCode", null, locationIds, "入庫先ロケーション");
- var backflush = reader.Bool("Backflush", false);
- var defects = ParseDefects(reader, table.Value(row, "Defects"), defectReasonIds);
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await execution.AddProductionRecordAsync(workOrderId!.Value,
- new ProductionRecordRequest(good, defect, startedAt!.Value, endedAt, locationId, backflush,
- scrap, rework, defects), userId, ct);
- if (outcome.Failed)
- {
- FailRow(reader, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- /// 製造条件データ。1行=1項目の記録
- private async Task ImportDataRecordsAsync(
- CsvTable table, List errors, string? userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
-
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- var workOrderId = ResolveWorkOrder(reader, workOrders);
- var controlItemCode = reader.Text("ControlItemCode", null);
- var numericValue = reader.NumberOrNull("NumericValue", null);
- var item = reader.Text("Item", null, 100);
- var value = reader.Text("Value", null, 500);
- if (reader.Failed)
- {
- continue;
- }
-
- int? instructionId = null;
- if (controlItemCode is not null)
- {
- // 展開時に作業指示へ写した指示(スナップショット)を引く。マスタの現在値では判定しない(Spec.md 5.7)
- var instruction = await db.WorkOrderControlItems.AsNoTracking()
- .FirstOrDefaultAsync(i => i.WorkOrderId == workOrderId && i.ItemCode == controlItemCode, ct);
- if (instruction is null)
- {
- reader.Fail(ApiText.T("工程管理項目 '{0}' はこの作業指示に展開されていません(ControlItemCode)。", controlItemCode));
- continue;
- }
- if (numericValue is null)
- {
- reader.Fail(ApiText.T("ControlItemCode を指定した行には NumericValue(数値)が必要です。"));
- continue;
- }
- instructionId = instruction.Id;
- item ??= instruction.ItemName;
- value ??= $"{numericValue}{instruction.Unit}";
- }
- if (item is null || value is null)
- {
- reader.Fail(ApiText.T("ControlItemCode を省略した行には Item(項目)と Value(値)が必要です。"));
- continue;
- }
-
- var outcome = await execution.AddDataRecordsAsync(workOrderId!.Value,
- [new DataRecordRequest(item, value, instructionId, numericValue)], userId, ct);
- if (outcome.Failed)
- {
- FailRow(reader, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- // ---- 検査・作業時間・トラブル(C-20 / B-30-30-02 / B-60-10)----
-
- ///
- /// 検査。InspectionKey が同じ行を1件の検査指示にまとめ、指示の発行(C-20-10-02)→実績の登録(C-20-10-03)
- /// →(Judge=true なら)総合判定(C-20-10-04)まで進める。1行=1項目×1サンプルの実績
- ///
- private async Task ImportInspectionsAsync(
- CsvTable table, List errors, string userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var itemIds = await db.InspectionItems.AsNoTracking()
- .ToDictionaryAsync(i => i.Code, i => i.Id, StringComparer.Ordinal, ct);
- var deviceIds = await db.InspectionDevices.AsNoTracking()
- .ToDictionaryAsync(d => d.Code, d => d.Id, StringComparer.Ordinal, ct);
-
- var created = 0;
- foreach (var group in table.Rows.GroupBy(row => table.Value(row, "InspectionKey")).Select(g => g.ToList()))
- {
- var head = new CsvRowReader(table, group[0], errors);
- head.RequiredText("InspectionKey");
- head.RequiredText("Type");
- var type = head.Enum("Type", InspectionOrderType.Receiving, CsvEnumLabels.InspectionOrderTypes);
- var lotNumber = head.Text("LotNumber", null);
- var workOrderId = type == InspectionOrderType.InProcess ? ResolveOptionalWorkOrder(head, workOrders) : null;
- var judge = head.Bool("Judge", false);
- var grade = head.Text("Grade", null, 50);
- var note = head.Text("Note", null, 1000);
- if (type == InspectionOrderType.InProcess && workOrderId is null && !head.Failed)
- {
- head.Fail(ApiText.T("工程内検査には OrderNo(指図番号)と Sequence(工程順序)が必要です。"));
- }
- int? lotId = null;
- if (type != InspectionOrderType.InProcess)
- {
- if (lotNumber is null)
- {
- head.Fail(ApiText.T("工程内検査以外は LotNumber(対象ロット番号)が必要です。"));
- }
- else
- {
- // 受入・生産実績の取込で前のファイルが作ったロットも指せるよう、DBを引く
- lotId = await db.Lots.Where(l => l.LotNumber == lotNumber)
- .Select(l => (int?)l.Id).FirstOrDefaultAsync(ct);
- if (lotId is null)
- {
- head.Fail(ApiText.T("ロット '{0}' は登録されていません(LotNumber)。", lotNumber));
- }
- }
- }
-
- var results = new List();
- var failed = head.Failed;
- foreach (var row in group)
- {
- var reader = row == group[0] ? head : new CsvRowReader(table, row, errors);
- reader.RequiredText("ItemCode");
- var itemId = reader.Reference("ItemCode", null, itemIds, "検査項目");
- var sampleNo = reader.IntOrNull("SampleNo", null, 1);
- var measured = reader.NumberOrNull("MeasuredValue", null);
- var text = reader.Text("TextValue", null, 500);
- InspectionJudgment? judgment = table.Value(row, "Judgment") is null
- ? null
- : reader.Enum("Judgment", InspectionJudgment.Pass, CsvEnumLabels.InspectionJudgments);
- var deviceId = reader.Reference("DeviceCode", null, deviceIds, "検査機");
- if (reader.Failed)
- {
- failed = true;
- continue;
- }
- results.Add(new InspectionResultRequest(itemId!.Value, sampleNo, measured, text, judgment, deviceId));
- }
- if (failed)
- {
- continue;
- }
-
- var outcome = await inspections.CreateAsync(new InspectionOrderCreateRequest(
- type, lotId, workOrderId, [.. results.Select(r => r.InspectionItemId).Distinct()], note), userId, ct);
- if (outcome.Value is { } order)
- {
- outcome = await inspections.AddResultsAsync(order.Id, results, userId, ct);
- if (!outcome.Failed && judge)
- {
- outcome = await inspections.JudgeAsync(order.Id, grade, userId, ct);
- }
- }
- if (outcome.Failed)
- {
- FailRow(head, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- /// 作業時間。1行=1記録(記録者は取り込んだユーザー)
- private async Task ImportWorkTimeRecordsAsync(
- CsvTable table, List errors, string userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- reader.RequiredText("Type");
- var type = reader.Enum("Type", WorkTimeType.Direct, CsvEnumLabels.WorkTimeTypes);
- var category = reader.Text("IndirectCategory", null, 100);
- var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
- var startedAt = RequiredDateTime(reader, "StartedAt");
- var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
- var note = reader.Text("Note", null, 500);
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await reports.AddWorkTimeAsync(
- new WorkTimeRequest(type, category, workOrderId, startedAt!.Value, endedAt, note), userId, ct);
- if (outcome.Failed)
- {
- FailRow(reader, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- /// 製造トラブル報告。1行=1報告(報告者は取り込んだユーザー)
- private async Task ImportTroubleReportsAsync(
- CsvTable table, List errors, string userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var equipmentIds = await db.Equipments.AsNoTracking()
- .ToDictionaryAsync(e => e.AssetNo, e => e.Id, StringComparer.Ordinal, ct);
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- var occurredAt = RequiredDateTime(reader, "OccurredAt");
- reader.RequiredText("Category");
- var category = reader.Enum("Category", TroubleCategory.Quality, CsvEnumLabels.TroubleCategories);
- var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
- var equipmentId = reader.Reference("EquipmentAssetNo", null, equipmentIds, "設備");
- var content = reader.RequiredText("Content", 2000);
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await reports.AddTroubleReportAsync(
- new TroubleReportRequest(occurredAt!.Value, category, workOrderId, equipmentId, content), userId, ct);
- if (outcome.Failed)
- {
- FailRow(reader, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- /// 設備稼働記録。1行=1区間(記録者は取り込んだユーザー)
- private async Task ImportEquipmentLogsAsync(
- CsvTable table, List errors, string? userId, CancellationToken ct)
- {
- var workOrders = await WorkOrderKeysAsync(ct);
- var equipmentIds = await db.Equipments.AsNoTracking()
- .ToDictionaryAsync(e => e.AssetNo, e => e.Id, StringComparer.Ordinal, ct);
- var created = 0;
- foreach (var row in table.Rows)
- {
- var reader = new CsvRowReader(table, row, errors);
- reader.RequiredText("EquipmentAssetNo");
- var equipmentId = reader.Reference("EquipmentAssetNo", null, equipmentIds, "設備");
- reader.RequiredText("Status");
- var status = reader.Enum("Status", EquipmentLogStatus.Running, CsvEnumLabels.EquipmentLogStatuses);
- var startedAt = RequiredDateTime(reader, "StartedAt");
- var endedAt = reader.DateTimeOrNull("EndedAt", FactoryOffset);
- var stopCause = reader.Text("StopCause", null, 500);
- var workOrderId = ResolveOptionalWorkOrder(reader, workOrders);
- var note = reader.Text("Note", null, 500);
- if (reader.Failed)
- {
- continue;
- }
-
- var outcome = await reports.AddEquipmentLogAsync(
- new EquipmentLogRequest(equipmentId!.Value, status, startedAt!.Value, endedAt, stopCause, note, workOrderId),
- userId, ct);
- if (outcome.Failed)
- {
- FailRow(reader, outcome.Error!);
- continue;
- }
- created++;
- }
- return created;
- }
-
- /// OrderNo を書いた行だけ作業指示を解決する(空欄なら紐づけない)
- private static int? ResolveOptionalWorkOrder(
- CsvRowReader reader, Dictionary<(string OrderNo, int Sequence), int> workOrders) =>
- reader.Text("OrderNo", null) is null ? null : ResolveWorkOrder(reader, workOrders);
-
// ---- 共通 ----
/// オフセットの無い日時を工場の時刻として読むためのオフセット
@@ -718,38 +146,14 @@ private void FailRow(CsvRowReader reader, string error)
return null;
}
+ /// OrderNo を書いた行だけ作業指示を解決する(空欄なら紐づけない)
+ private static int? ResolveOptionalWorkOrder(
+ CsvRowReader reader, Dictionary<(string OrderNo, int Sequence), int> workOrders) =>
+ reader.Text("OrderNo", null) is null ? null : ResolveWorkOrder(reader, workOrders);
+
private DateTimeOffset? RequiredDateTime(CsvRowReader reader, string column)
{
var text = reader.RequiredText(column);
return text.Length == 0 ? null : reader.DateTimeOrNull(column, FactoryOffset);
}
-
- /// 不良理由別の内訳(DR-02=2;DR-03=1)を読む
- private static List? ParseDefects(
- CsvRowReader reader, string? raw, IReadOnlyDictionary reasonIds)
- {
- if (raw is null)
- {
- return null;
- }
- var result = new List();
- foreach (var part in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
- {
- var pair = part.Split('=', 2, StringSplitOptions.TrimEntries);
- if (pair.Length != 2
- || !decimal.TryParse(pair[1], NumberStyles.Number, CultureInfo.InvariantCulture, out var quantity)
- || quantity < 0)
- {
- reader.Fail(ApiText.T("Defects は 不良理由コード=数量 をセミコロンで区切って指定してください('{0}')。", part));
- continue;
- }
- if (!reasonIds.TryGetValue(pair[0], out var reasonId))
- {
- reader.Fail(ApiText.T("不良理由 '{0}' は登録されていません(Defects)。", pair[0]));
- continue;
- }
- result.Add(new ProductionDefectRequest(reasonId, quantity));
- }
- return result;
- }
}
diff --git a/src/MesApp.Api/Services/InspectionDeviceService.cs b/src/MesApp.Api/Services/InspectionDeviceService.cs
new file mode 100644
index 0000000..9adfe5d
--- /dev/null
+++ b/src/MesApp.Api/Services/InspectionDeviceService.cs
@@ -0,0 +1,57 @@
+using MesApp.Api.Localization;
+using MesApp.Core.Abstractions;
+using MesApp.Core.Contracts.Masters;
+using MesApp.Core.Entities;
+using MesApp.Infrastructure;
+
+namespace MesApp.Api.Services;
+
+///
+/// 検査機・測定器の校正の記録(C-20-50-03)。
+/// 単票API(InspectionDevicesController)と実績CSV取込の両方から呼ぶ。
+/// 保存と監査ログまで行う。トランザクションは呼び出し側が張る。
+///
+public sealed class InspectionDeviceService(MesAppDbContext db, IAuditLogger auditLogger)
+{
+ ///
+ /// 校正の実施を記録する。マスタの現在値(最終校正日・次回期限)を更新しつつ履歴を1件残す。
+ /// 次回期限の指定が無ければ校正周期から置く(周期も無ければ期限なしになる)
+ ///
+ public async Task> AddCalibrationAsync(
+ int deviceId, InspectionDeviceCalibrationRequest request, string? userId, CancellationToken ct)
+ {
+ var device = await db.InspectionDevices.FindAsync([deviceId], ct);
+ if (device is null)
+ {
+ return Outcome.NotFound(ApiText.T("検査機が見つかりません。"));
+ }
+
+ var nextDue = request.NextDueOn
+ ?? (device.CalibrationCycleDays is { } cycle
+ ? request.CalibratedOn.AddDays(cycle)
+ : null);
+ var calibration = new InspectionDeviceCalibration
+ {
+ InspectionDeviceId = deviceId,
+ CalibratedOn = request.CalibratedOn,
+ NextDueOn = nextDue,
+ Result = request.Result,
+ PerformedByUserId = userId,
+ };
+ db.InspectionDeviceCalibrations.Add(calibration);
+
+ var before = new { device.CalibratedOn, device.CalibrationDueOn };
+ device.CalibratedOn = request.CalibratedOn;
+ device.CalibrationDueOn = nextDue;
+ await db.SaveChangesAsync(ct);
+
+ await auditLogger.LogAsync("Master", "Calibrate", nameof(InspectionDevice), deviceId.ToString(),
+ detail: new
+ {
+ before,
+ after = new { device.CalibratedOn, device.CalibrationDueOn },
+ reason = request.Result,
+ }, ct: ct);
+ return Outcome.Ok(calibration);
+ }
+}
diff --git a/src/MesApp.Api/Services/LotOperationService.cs b/src/MesApp.Api/Services/LotOperationService.cs
index 7ee213b..39f0c2e 100644
--- a/src/MesApp.Api/Services/LotOperationService.cs
+++ b/src/MesApp.Api/Services/LotOperationService.cs
@@ -310,8 +310,11 @@ private async Task> DeriveAsync(
db.Lots.Add(newLot);
// newLot.Idの確定に一度SaveChangesが要るため保存が2回に分かれる。
- // 途中で失敗すると元ロットから減った在庫が新ロットに入らず消えるので、トランザクションでまとめる
- await using var transaction = await db.Database.BeginTransactionAsync(ct);
+ // 途中で失敗すると元ロットから減った在庫が新ロットに入らず消えるので、トランザクションでまとめる。
+ // 呼び出し側が既に張っていれば(実績CSVの取込)それに乗る(入れ子のトランザクションは張れない)
+ await using var transaction = db.Database.CurrentTransaction is null
+ ? await db.Database.BeginTransactionAsync(ct)
+ : null;
try
{
await inventory.RemoveAsync(lot, locationId, quantity,
@@ -327,7 +330,10 @@ await inventory.AddAsync(newLot, locationId, quantity,
}
await db.SaveChangesAsync(ct);
await audit(newLot);
- await transaction.CommitAsync(ct);
+ if (transaction is not null)
+ {
+ await transaction.CommitAsync(ct);
+ }
return Outcome.Ok(newLot);
}
diff --git a/src/MesApp.Api/Services/MaintenanceOrderService.cs b/src/MesApp.Api/Services/MaintenanceOrderService.cs
index 1b7d5e2..68846f2 100644
--- a/src/MesApp.Api/Services/MaintenanceOrderService.cs
+++ b/src/MesApp.Api/Services/MaintenanceOrderService.cs
@@ -26,9 +26,18 @@ public sealed class MaintenanceOrderService(
IBusinessDateService businessDate,
IAuditLogger auditLogger)
{
- /// 保全指示の作成。保全計画から作る場合は計画を「指示済み」にする
+ ///
+ /// 計画保全の指示を作れるロール(突発依頼は全ユーザーが起票できる。E-30-30-01)。
+ /// 単票APIとCSV取込で同じ定数を使う
+ ///
+ public const string PlannedOrderRoles = MesRoleGroups.MaintenanceManage;
+
+ ///
+ /// 保全指示の作成。保全計画から作る場合は計画を「指示済み」にする。
+ /// maintenanceNo を渡すとその番号で登録する(CSV取込で後続の行から指示を指すため。空なら自動採番)
+ ///
public async Task> CreateAsync(
- MaintenanceOrderCreateRequest request, string? userId, CancellationToken ct)
+ MaintenanceOrderCreateRequest request, string? maintenanceNo, string? userId, CancellationToken ct)
{
if ((request.EquipmentId is null) == (request.ToolId is null))
{
@@ -49,6 +58,20 @@ public async Task> CreateAsync(
return Outcome.Invalid(ApiText.T("存在しない(または無効な)手順書IDです。"));
}
+ if (string.IsNullOrWhiteSpace(maintenanceNo))
+ {
+ maintenanceNo = await numbering.NextMaintenanceNoAsync(ct);
+ }
+ else if (NumberingService.IsAutoNumberFormat(maintenanceNo, NumberingService.MaintenanceNoPrefix))
+ {
+ return Outcome.Invalid(
+ ApiText.T("保全指示番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", maintenanceNo, NumberingService.MaintenanceNoPrefix));
+ }
+ else if (await db.MaintenanceOrders.AnyAsync(x => x.OrderNo == maintenanceNo, ct))
+ {
+ return Outcome.Conflict(ApiText.T("保全指示番号 '{0}' は既に存在します。", maintenanceNo));
+ }
+
MaintenancePlan? plan = null;
if (request.MaintenancePlanId is int planId)
{
@@ -66,7 +89,7 @@ public async Task> CreateAsync(
var order = new MaintenanceOrder
{
- OrderNo = await numbering.NextMaintenanceNoAsync(ct),
+ OrderNo = maintenanceNo,
EquipmentId = request.EquipmentId,
ToolId = request.ToolId,
MaintenancePlanId = plan?.Id,
@@ -231,6 +254,33 @@ public async Task> CancelAsync(int orderId, Cancellati
return Outcome.Ok(order);
}
+ /// 保全計画の作成(E-30-10-01)。単票API(MaintenancePlansController)と実績CSV取込で共通
+ public async Task> CreatePlanAsync(
+ MaintenancePlanRequest request, string? userId, CancellationToken ct)
+ {
+ var equipment = await db.Equipments.FirstOrDefaultAsync(e => e.Id == request.EquipmentId, ct);
+ if (equipment is null || !equipment.IsActive)
+ {
+ return Outcome.Invalid(ApiText.T("存在しない(または無効な)設備IDです。"));
+ }
+
+ var plan = new MaintenancePlan
+ {
+ EquipmentId = request.EquipmentId,
+ Category = request.Category,
+ PlanYear = request.PlanYear,
+ ScheduledDate = request.ScheduledDate,
+ CycleDays = request.CycleDays,
+ Note = request.Note,
+ CreatedByUserId = userId,
+ };
+ db.MaintenancePlans.Add(plan);
+ await db.SaveChangesAsync(ct);
+ await auditLogger.LogAsync("Maintenance", "PlanCreate", nameof(MaintenancePlan), plan.Id.ToString(),
+ detail: $"equipment={equipment.AssetNo}, year={plan.PlanYear}", ct: ct);
+ return Outcome.Ok(plan);
+ }
+
/// 保全計画の取消(E-30-10)
public async Task> CancelPlanAsync(int planId, CancellationToken ct)
{
diff --git a/src/MesApp.Api/Services/ManufacturingOrderService.cs b/src/MesApp.Api/Services/ManufacturingOrderService.cs
index 7c9d10e..aa9d7c3 100644
--- a/src/MesApp.Api/Services/ManufacturingOrderService.cs
+++ b/src/MesApp.Api/Services/ManufacturingOrderService.cs
@@ -29,9 +29,6 @@ public sealed class ManufacturingOrderService(
WorkOrderStatusService workOrderStatus,
IAuditLogger auditLogger)
{
- /// 自動採番の指図番号の接頭辞(手入力の番号と衝突させないため、手入力では使わせない)
- public const string AutoOrderNoPrefix = "MO";
-
///
/// 指図登録(A-20-10-01 手動登録、B-10-10-04 突発、B-70-10-01 リワーク)。
/// orderNo を渡すとその番号で登録する(CSV取込で後続の実績から指図を指すため。空なら自動採番)
@@ -67,11 +64,10 @@ public async Task CreateAsync(
{
orderNo = await numbering.NextOrderNoAsync(ct);
}
- else if (orderNo.StartsWith(AutoOrderNoPrefix, StringComparison.OrdinalIgnoreCase))
+ else if (NumberingService.IsAutoNumberFormat(orderNo, NumberingService.OrderNoPrefix))
{
- // 自動採番の連番は採番テーブルで管理しており、同じ形式の手入力番号があると後で衝突する
return OrderOutcome.Invalid(
- ApiText.T("指図番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", orderNo, AutoOrderNoPrefix));
+ ApiText.T("指図番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", orderNo, NumberingService.OrderNoPrefix));
}
else if (await db.ManufacturingOrders.AnyAsync(o => o.OrderNo == orderNo, ct))
{
diff --git a/src/MesApp.Api/Services/MasterCsvKinds.cs b/src/MesApp.Api/Services/MasterCsvKinds.cs
index 9ca18e6..723298c 100644
--- a/src/MesApp.Api/Services/MasterCsvKinds.cs
+++ b/src/MesApp.Api/Services/MasterCsvKinds.cs
@@ -25,6 +25,7 @@ public static class MasterCsvKinds
public const string Bom = "bom";
public const string Routing = "routing";
public const string WorkProcedures = "work-procedures";
+ public const string MaintenanceProcedures = "maintenance-procedures";
public const string Shifts = "shifts";
public const string InspectionDevices = "inspection-devices";
public const string Users = "users";
@@ -189,6 +190,20 @@ public static class MasterCsvKinds
new("Reference", "手順書の所在", false, "別システムの文書番号・URLなど。手順ステップを書かない場合は必須"),
new("IsActive", "有効", false, "true / false"),
]),
+ // 保全手順書(E-10-20)。単票の api/maintenance-procedures と同じく保全の権限で取り込む
+ new(MaintenanceProcedures, "保全手順書", false,
+ [
+ new("ProcedureNo", "手順書番号", true, "既存と一致すれば更新(版数+1)、無ければ新規登録"),
+ new("Title", "表題", true, null),
+ new("TargetEquipmentAssetNo", "対象設備の資産番号", false, "設備用の手順書。空欄で解除"),
+ new("TargetToolCode", "対象治工具コード", false, "治工具メンテナンス用の手順書。空欄で解除"),
+ new("RequiredSkillCode", "必要スキル・資格コード", false, "保全実績の登録時に実施者と照合する。空欄で解除"),
+ new("Steps", "手順ステップ", true, null),
+ new("IsActive", "有効", false, "true / false"),
+ ])
+ {
+ WriteRoles = MesRoleGroups.MaintenanceManage,
+ },
new(Shifts, "勤務シフト(直)", true,
[
new("Code", "シフトコード", true, "既存と一致すれば更新、無ければ新規登録"),
@@ -256,7 +271,7 @@ public static class MasterCsvKinds
[
WorkCenters, Processes, Locations, Products, Skills, Shifts, Equipments, EquipmentParts, Tools,
Checklists, DefectReasons, InspectionItems, ControlItems, InspectionDevices, Bom, WorkProcedures,
- Routing, Users, UserSkills,
+ Routing, Users, UserSkills, MaintenanceProcedures,
];
///
@@ -312,6 +327,40 @@ public static class CsvEnumLabels
("可", ShipmentJudgmentResult.Approved), ("保留", ShipmentJudgmentResult.Hold),
("特採", ShipmentJudgmentResult.SpecialAcceptance), ("特別採用", ShipmentJudgmentResult.SpecialAcceptance));
+ public static readonly IReadOnlyDictionary NonconformanceSources = Build(
+ ("生産実績", NonconformanceSource.Production), ("生産", NonconformanceSource.Production),
+ ("検査", NonconformanceSource.Inspection), ("受入", NonconformanceSource.Receiving));
+
+ public static readonly IReadOnlyDictionary NonconformanceActions = Build(
+ ("リワーク", NonconformanceAction.Rework), ("保留", NonconformanceAction.Hold),
+ ("廃棄", NonconformanceAction.Discard), ("特採", NonconformanceAction.SpecialAcceptance),
+ ("特別採用", NonconformanceAction.SpecialAcceptance));
+
+ /// サンプルの保管の終わらせ方(保管中 Stored は指定できないので含めない)
+ public static readonly IReadOnlyDictionary SampleCloseStatuses =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Consumed"] = SampleStorageStatus.Consumed, ["払出"] = SampleStorageStatus.Consumed,
+ ["払出済"] = SampleStorageStatus.Consumed,
+ ["Disposed"] = SampleStorageStatus.Disposed, ["廃棄"] = SampleStorageStatus.Disposed,
+ ["廃棄済"] = SampleStorageStatus.Disposed,
+ };
+
+ public static readonly IReadOnlyDictionary PickingOrderTypes = Build(
+ ("工程払出", PickingOrderType.ProcessIssue), ("出荷", PickingOrderType.Shipping));
+
+ public static readonly IReadOnlyDictionary LotStockStatuses = Build(
+ ("正常", LotStockStatus.Normal), ("保留", LotStockStatus.OnHold),
+ ("検査待ち", LotStockStatus.AwaitingInspection), ("不良", LotStockStatus.Defective),
+ ("廃棄予定", LotStockStatus.ToBeDiscarded));
+
+ public static readonly IReadOnlyDictionary MaintenanceCategories = Build(
+ ("定期", MaintenanceCategory.Periodic), ("計画外", MaintenanceCategory.Unplanned));
+
+ public static readonly IReadOnlyDictionary MaintenanceRequestTypes = Build(
+ ("計画", MaintenanceRequestType.Planned), ("計画保全", MaintenanceRequestType.Planned),
+ ("突発", MaintenanceRequestType.Spot), ("突発依頼", MaintenanceRequestType.Spot));
+
public static readonly IReadOnlyDictionary MakeOrBuys = Build(
("内製", MakeOrBuy.InHouse), ("外注", MakeOrBuy.Outsourced));
diff --git a/src/MesApp.Api/Services/MasterCsvService.Import.cs b/src/MesApp.Api/Services/MasterCsvService.Import.cs
index 7148069..5c964b1 100644
--- a/src/MesApp.Api/Services/MasterCsvService.Import.cs
+++ b/src/MesApp.Api/Services/MasterCsvService.Import.cs
@@ -95,6 +95,9 @@ private async Task ImportOneAsync(
case MasterCsvKinds.WorkProcedures:
await ImportWorkProceduresAsync(table, errors, counter, ct);
break;
+ case MasterCsvKinds.MaintenanceProcedures:
+ await ImportMaintenanceProceduresAsync(table, errors, counter, ct);
+ break;
case MasterCsvKinds.InspectionDevices:
await ImportInspectionDevicesAsync(table, errors, counter, ct);
break;
@@ -1086,6 +1089,67 @@ private async Task ImportWorkProceduresAsync(
}
}
+ ///
+ /// 保全手順書(E-10-20)。更新は版数を上げる(単票APIの更新と同じ。E-20-30-06)。
+ /// 単票APIは無効化を制限していないため、ここでも参照中の手順書の無効化を拒否しない
+ /// (保全指示は手順書を版数ごと固定せず、無効な手順書は新しい指示で選べなくなるだけ)
+ ///
+ private async Task ImportMaintenanceProceduresAsync(
+ CsvTable table, List errors, ImportCounter counter, CancellationToken ct)
+ {
+ var byNo = await db.MaintenanceProcedures.ToDictionaryAsync(p => p.ProcedureNo, StringComparer.Ordinal, ct);
+ var equipmentIds = await db.Equipments.AsNoTracking()
+ .ToDictionaryAsync(e => e.AssetNo, e => e.Id, StringComparer.Ordinal, ct);
+ var toolIds = await db.Tools.AsNoTracking()
+ .ToDictionaryAsync(t => t.Code, t => t.Id, StringComparer.Ordinal, ct);
+ var skillIds = await db.Skills.AsNoTracking()
+ .ToDictionaryAsync(s => s.Code, s => s.Id, StringComparer.Ordinal, ct);
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (var row in table.Rows)
+ {
+ var reader = new CsvRowReader(table, row, errors);
+ var procedureNo = reader.RequiredText("ProcedureNo", 50);
+ if (reader.Failed || !CheckUnique(reader, seen, procedureNo, "手順書番号"))
+ {
+ continue;
+ }
+
+ var isNew = !byNo.TryGetValue(procedureNo, out var procedure);
+ procedure ??= new MaintenanceProcedure { ProcedureNo = procedureNo };
+
+ var title = reader.RequiredText("Title", 200);
+ var equipmentId = reader.Reference("TargetEquipmentAssetNo", procedure.TargetEquipmentId, equipmentIds, "設備");
+ var toolId = reader.Reference("TargetToolCode", procedure.TargetToolId, toolIds, "治工具");
+ var skillId = reader.Reference("RequiredSkillCode", procedure.RequiredSkillId, skillIds, "スキル・資格");
+ var steps = reader.RequiredText("Steps", 4000);
+ var isActive = reader.Bool("IsActive", procedure.IsActive);
+ if (reader.Failed)
+ {
+ continue;
+ }
+
+ procedure.Title = title;
+ procedure.TargetEquipmentId = equipmentId;
+ procedure.TargetToolId = toolId;
+ procedure.RequiredSkillId = skillId;
+ procedure.Steps = steps;
+ procedure.IsActive = isActive;
+
+ if (isNew)
+ {
+ db.MaintenanceProcedures.Add(procedure);
+ byNo[procedureNo] = procedure;
+ counter.Created++;
+ }
+ else
+ {
+ procedure.Version++; // 取込による見直しも版数を上げる(E-20-30-06)
+ counter.Updated++;
+ }
+ }
+ }
+
private async Task ImportRoutingAsync(
CsvTable table, List errors, ImportCounter counter, CancellationToken ct)
{
diff --git a/src/MesApp.Api/Services/MasterCsvService.cs b/src/MesApp.Api/Services/MasterCsvService.cs
index 64c6570..a2f28ea 100644
--- a/src/MesApp.Api/Services/MasterCsvService.cs
+++ b/src/MesApp.Api/Services/MasterCsvService.cs
@@ -43,6 +43,7 @@ public async Task ExportAsync(CsvKindInfo kind, bool includeInactive, Ca
MasterCsvKinds.Bom => await ExportBomAsync(ct),
MasterCsvKinds.Routing => await ExportRoutingAsync(ct),
MasterCsvKinds.WorkProcedures => await ExportWorkProceduresAsync(includeInactive, ct),
+ MasterCsvKinds.MaintenanceProcedures => await ExportMaintenanceProceduresAsync(includeInactive, ct),
MasterCsvKinds.Shifts => await ExportShiftsAsync(includeInactive, ct),
MasterCsvKinds.InspectionDevices => await ExportInspectionDevicesAsync(includeInactive, ct),
MasterCsvKinds.Users => await ExportUsersAsync(includeInactive, ct),
@@ -255,6 +256,19 @@ public async Task ExportAsync(CsvKindInfo kind, bool includeInactive, Ca
})];
}
+ private async Task> ExportMaintenanceProceduresAsync(bool includeInactive, CancellationToken ct)
+ {
+ var items = await db.MaintenanceProcedures.AsNoTracking()
+ .Include(p => p.TargetEquipment).Include(p => p.TargetTool).Include(p => p.RequiredSkill)
+ .Where(p => includeInactive || p.IsActive)
+ .OrderBy(p => p.ProcedureNo).ToListAsync(ct);
+ return [.. items.Select(p => new string?[]
+ {
+ p.ProcedureNo, p.Title, p.TargetEquipment?.AssetNo, p.TargetTool?.Code, p.RequiredSkill?.Code,
+ p.Steps, Bool(p.IsActive),
+ })];
+ }
+
private async Task> ExportUsersAsync(bool includeInactive, CancellationToken ct)
{
var users = await db.Users.AsNoTracking().Include(u => u.WorkCenter).Include(u => u.Shift)
diff --git a/src/MesApp.Api/Services/NonconformanceService.cs b/src/MesApp.Api/Services/NonconformanceService.cs
index 9d33b0d..866f3b3 100644
--- a/src/MesApp.Api/Services/NonconformanceService.cs
+++ b/src/MesApp.Api/Services/NonconformanceService.cs
@@ -19,9 +19,12 @@ public sealed class NonconformanceService(
LotStatusService lotStatus,
IAuditLogger auditLogger)
{
- /// 逸脱・品質不具合の記録(B-40-30-01〜02)
+ ///
+ /// 逸脱・品質不具合の記録(B-40-30-01〜02)。
+ /// reportNo を渡すとその番号で登録する(CSV取込で後続の行から不適合を指すため。空なら自動採番)
+ ///
public async Task> CreateAsync(
- NonconformanceCreateRequest request, string? userId, CancellationToken ct)
+ NonconformanceCreateRequest request, string? reportNo, string? userId, CancellationToken ct)
{
if (request.LotId is int lotId && !await db.Lots.AnyAsync(l => l.Id == lotId, ct))
{
@@ -38,9 +41,23 @@ public async Task> CreateAsync(
return Outcome.Invalid(ApiText.T("存在しない検査指示IDです。"));
}
+ if (string.IsNullOrWhiteSpace(reportNo))
+ {
+ reportNo = await numbering.NextNonconformanceNoAsync(ct);
+ }
+ else if (NumberingService.IsAutoNumberFormat(reportNo, NumberingService.NonconformanceNoPrefix))
+ {
+ return Outcome.Invalid(
+ ApiText.T("不適合番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", reportNo, NumberingService.NonconformanceNoPrefix));
+ }
+ else if (await db.NonconformanceReports.AnyAsync(x => x.ReportNo == reportNo, ct))
+ {
+ return Outcome.Conflict(ApiText.T("不適合番号 '{0}' は既に存在します。", reportNo));
+ }
+
var report = new NonconformanceReport
{
- ReportNo = await numbering.NextNonconformanceNoAsync(ct),
+ ReportNo = reportNo,
Source = request.Source,
LotId = request.LotId,
WorkOrderId = request.WorkOrderId,
diff --git a/src/MesApp.Api/Services/NumberingService.cs b/src/MesApp.Api/Services/NumberingService.cs
index 680e672..892ec25 100644
--- a/src/MesApp.Api/Services/NumberingService.cs
+++ b/src/MesApp.Api/Services/NumberingService.cs
@@ -26,9 +26,27 @@ namespace MesApp.Api.Services;
///
public class NumberingService(MesAppDbContext db, IBusinessDateService businessDate)
{
+ // 自動採番の接頭辞。手入力の番号(CSV取込で後続の行から指すため)ではこれで始めさせない(IsAutoNumberFormat)
+ public const string OrderNoPrefix = "MO";
+ public const string PickingNoPrefix = "PK";
+ public const string ShippingNoPrefix = "SH";
+ public const string StocktakeNoPrefix = "ST";
+ public const string InspectionNoPrefix = "IN";
+ public const string NonconformanceNoPrefix = "NC";
+ public const string MaintenanceNoPrefix = "MT";
+ public const string JudgmentNoPrefix = "SJ";
+ public const string SampleNoPrefix = "SP";
+
+ ///
+ /// 手入力の番号が自動採番の形式(接頭辞)と重なるか。
+ /// 自動採番の連番は採番テーブルで管理しており、同じ形式の手入力番号があると後で払い出す番号と衝突する
+ ///
+ public static bool IsAutoNumberFormat(string number, string prefix) =>
+ number.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
+
public async Task NextOrderNoAsync(CancellationToken ct = default)
{
- var prefix = $"MO{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{OrderNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.ManufacturingOrders.Where(o => o.OrderNo.StartsWith(prefix)).Select(o => o.OrderNo),
prefix, ct);
@@ -46,7 +64,7 @@ public async Task NextLotNumberAsync(string productCode, CancellationTok
public async Task NextPickingNoAsync(CancellationToken ct = default)
{
- var prefix = $"PK{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{PickingNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.PickingOrders.Where(p => p.OrderNo.StartsWith(prefix)).Select(p => p.OrderNo),
prefix, ct);
@@ -55,7 +73,7 @@ public async Task NextPickingNoAsync(CancellationToken ct = default)
public async Task NextShippingNoAsync(CancellationToken ct = default)
{
- var prefix = $"SH{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{ShippingNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.ShippingOrders.Where(s => s.ShippingNo.StartsWith(prefix)).Select(s => s.ShippingNo),
prefix, ct);
@@ -64,7 +82,7 @@ public async Task NextShippingNoAsync(CancellationToken ct = default)
public async Task NextStocktakeNoAsync(CancellationToken ct = default)
{
- var prefix = $"ST{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{StocktakeNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.Stocktakes.Where(s => s.StocktakeNo.StartsWith(prefix)).Select(s => s.StocktakeNo),
prefix, ct);
@@ -73,7 +91,7 @@ public async Task NextStocktakeNoAsync(CancellationToken ct = default)
public async Task NextInspectionNoAsync(CancellationToken ct = default)
{
- var prefix = $"IN{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{InspectionNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.InspectionOrders.Where(i => i.OrderNo.StartsWith(prefix)).Select(i => i.OrderNo),
prefix, ct);
@@ -82,7 +100,7 @@ public async Task NextInspectionNoAsync(CancellationToken ct = default)
public async Task NextNonconformanceNoAsync(CancellationToken ct = default)
{
- var prefix = $"NC{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{NonconformanceNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.NonconformanceReports.Where(n => n.ReportNo.StartsWith(prefix)).Select(n => n.ReportNo),
prefix, ct);
@@ -91,7 +109,7 @@ public async Task NextNonconformanceNoAsync(CancellationToken ct = defau
public async Task NextMaintenanceNoAsync(CancellationToken ct = default)
{
- var prefix = $"MT{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{MaintenanceNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.MaintenanceOrders.Where(m => m.OrderNo.StartsWith(prefix)).Select(m => m.OrderNo),
prefix, ct);
@@ -100,7 +118,7 @@ public async Task NextMaintenanceNoAsync(CancellationToken ct = default)
public async Task NextJudgmentNoAsync(CancellationToken ct = default)
{
- var prefix = $"SJ{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{JudgmentNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.ShipmentJudgments.Where(j => j.JudgmentNo.StartsWith(prefix)).Select(j => j.JudgmentNo),
prefix, ct);
@@ -109,7 +127,7 @@ public async Task NextJudgmentNoAsync(CancellationToken ct = default)
public async Task NextSampleNoAsync(CancellationToken ct = default)
{
- var prefix = $"SP{businessDate.Today:yyyyMMdd}-";
+ var prefix = $"{SampleNoPrefix}{businessDate.Today:yyyyMMdd}-";
var seq = await NextSequenceAsync(
db.SampleStorages.Where(x => x.SampleNo.StartsWith(prefix)).Select(x => x.SampleNo),
prefix, ct);
diff --git a/src/MesApp.Api/Services/PickingService.cs b/src/MesApp.Api/Services/PickingService.cs
index 050b70d..d6f2d25 100644
--- a/src/MesApp.Api/Services/PickingService.cs
+++ b/src/MesApp.Api/Services/PickingService.cs
@@ -19,9 +19,12 @@ public sealed class PickingService(
NumberingService numbering,
IAuditLogger auditLogger)
{
- /// ピッキング指示の作成(払出先=作業指示または出荷指示。FEFOで自動引当)
+ ///
+ /// ピッキング指示の作成(払出先=作業指示または出荷指示。FEFOで自動引当)。
+ /// pickingNo を渡すとその番号で登録する(CSV取込で後続の行から指示を指すため。空なら自動採番)
+ ///
public async Task> CreateAsync(
- PickingOrderCreateRequest request, string? userId, CancellationToken ct)
+ PickingOrderCreateRequest request, string? pickingNo, string? userId, CancellationToken ct)
{
if (request.Lines.Count == 0)
{
@@ -41,9 +44,23 @@ public async Task> CreateAsync(
return Outcome.Invalid(ApiText.T("出荷ピッキングには有効な出荷指示ID(shippingOrderId)が必要です。"));
}
+ if (string.IsNullOrWhiteSpace(pickingNo))
+ {
+ pickingNo = await numbering.NextPickingNoAsync(ct);
+ }
+ else if (NumberingService.IsAutoNumberFormat(pickingNo, NumberingService.PickingNoPrefix))
+ {
+ return Outcome.Invalid(
+ ApiText.T("ピッキング番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", pickingNo, NumberingService.PickingNoPrefix));
+ }
+ else if (await db.PickingOrders.AnyAsync(x => x.OrderNo == pickingNo, ct))
+ {
+ return Outcome.Conflict(ApiText.T("ピッキング番号 '{0}' は既に存在します。", pickingNo));
+ }
+
var order = new PickingOrder
{
- OrderNo = await numbering.NextPickingNoAsync(ct),
+ OrderNo = pickingNo,
Type = request.Type,
WorkOrderId = request.Type == PickingOrderType.ProcessIssue ? request.WorkOrderId : null,
ShippingOrderId = request.Type == PickingOrderType.Shipping ? request.ShippingOrderId : null,
diff --git a/src/MesApp.Api/Services/SampleStorageService.cs b/src/MesApp.Api/Services/SampleStorageService.cs
new file mode 100644
index 0000000..5ece938
--- /dev/null
+++ b/src/MesApp.Api/Services/SampleStorageService.cs
@@ -0,0 +1,144 @@
+using MesApp.Api.Localization;
+using MesApp.Core.Abstractions;
+using MesApp.Core.Contracts.Inventory;
+using MesApp.Core.Entities;
+using MesApp.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+///
+/// サンプル品の保管(D-40-50-01):採取と保管の終了(払出・廃棄)。
+/// 単票API(SampleStoragesController)と実績CSV取込の両方から呼ぶ。サンプルの状態はここでだけ変更する。
+/// 保存と監査ログまで行う。トランザクションは呼び出し側が張る。
+///
+public sealed class SampleStorageService(
+ MesAppDbContext db,
+ InventoryService inventory,
+ NumberingService numbering,
+ IBusinessDateService businessDate,
+ IAuditLogger auditLogger)
+{
+ ///
+ /// サンプルの採取。採取した分は在庫から抜く
+ /// (保管棚へ移り、出荷・投入には使えない。在庫に残すと引当・先入れ先出しの対象になってしまう)。
+ /// sampleNo を渡すとその番号で登録する(CSV取込用。空なら自動採番)
+ ///
+ public async Task> CollectAsync(
+ SampleCollectRequest request, string? sampleNo, string? userId, CancellationToken ct)
+ {
+ var lot = await db.Lots.FirstOrDefaultAsync(l => l.Id == request.LotId, ct);
+ if (lot is null)
+ {
+ return Outcome.NotFound(ApiText.T("ロットID {0} は登録されていません。", request.LotId));
+ }
+ if (!await db.Locations.AnyAsync(l => l.Id == request.StorageLocationId && l.IsActive, ct))
+ {
+ return Outcome.Invalid(ApiText.T("保管場所が見つからないか無効です。"));
+ }
+ if (request.InspectionOrderId is { } inspectionId
+ && !await db.InspectionOrders.AnyAsync(i => i.Id == inspectionId, ct))
+ {
+ return Outcome.Invalid(ApiText.T("検査指示ID {0} は登録されていません。", inspectionId));
+ }
+
+ // 採取元の在庫は、サンプルの保管場所ではなく現物があった場所から抜く
+ var from = await db.InventoryStocks.AsNoTracking()
+ .Where(s => s.LotId == lot.Id && s.Quantity > 0)
+ .OrderByDescending(s => s.Quantity)
+ .Select(s => (int?)s.LocationId)
+ .FirstOrDefaultAsync(ct);
+ if (from is null)
+ {
+ return Outcome.Invalid(ApiText.T("ロット {0} の在庫がありません。", lot.LotNumber));
+ }
+
+ if (string.IsNullOrWhiteSpace(sampleNo))
+ {
+ sampleNo = await numbering.NextSampleNoAsync(ct);
+ }
+ else if (NumberingService.IsAutoNumberFormat(sampleNo, NumberingService.SampleNoPrefix))
+ {
+ return Outcome.Invalid(
+ ApiText.T("サンプル番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", sampleNo, NumberingService.SampleNoPrefix));
+ }
+ else if (await db.SampleStorages.AnyAsync(x => x.SampleNo == sampleNo, ct))
+ {
+ return Outcome.Conflict(ApiText.T("サンプル番号 '{0}' は既に存在します。", sampleNo));
+ }
+
+ var sample = new SampleStorage
+ {
+ SampleNo = sampleNo,
+ ProductId = lot.ProductId,
+ LotId = lot.Id,
+ InspectionOrderId = request.InspectionOrderId,
+ Quantity = request.Quantity,
+ StorageLocationId = request.StorageLocationId,
+ CollectedOn = businessDate.Today,
+ RetainUntil = request.RetainUntil,
+ Status = SampleStorageStatus.Stored,
+ CollectedByUserId = userId,
+ Note = request.Note,
+ };
+ db.SampleStorages.Add(sample);
+
+ try
+ {
+ await inventory.RemoveAsync(lot, from.Value, request.Quantity,
+ InventoryTransactionType.SampleRetention, userId,
+ note: $"サンプル採取 {sample.SampleNo}", ct: ct);
+ }
+ catch (InventoryException ex)
+ {
+ return Outcome.Invalid(ex.Message);
+ }
+
+ await db.SaveChangesAsync(ct);
+ await auditLogger.LogAsync("Inventory", "SampleCollect", nameof(SampleStorage),
+ sample.Id.ToString(),
+ detail: $"sample={sample.SampleNo} lot={lot.LotNumber} qty={request.Quantity}", ct: ct);
+ return Outcome.Ok(sample);
+ }
+
+ ///
+ /// 保管の終了(払出・廃棄)。**在庫は動かさない**(採取時に既に抜いてあるため)。
+ /// 保管期限前の処分も禁止しない——再試験のための払出は期限前に起こるのが普通で、
+ /// 止めると運用が画面の外へ出てしまう。期限前だったかどうかは監査ログに残す
+ ///
+ public async Task> CloseAsync(
+ int id, SampleCloseRequest request, string? userId, CancellationToken ct)
+ {
+ var sample = await db.SampleStorages.FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (sample is null)
+ {
+ return Outcome.NotFound(ApiText.T("サンプルが見つかりません。"));
+ }
+ if (sample.Status != SampleStorageStatus.Stored)
+ {
+ return Outcome.Conflict(ApiText.T("サンプル {0} は既に保管を終えています。", sample.SampleNo));
+ }
+ if (request.Status is not (SampleStorageStatus.Consumed or SampleStorageStatus.Disposed))
+ {
+ return Outcome.Invalid(ApiText.T("払出済か廃棄済かを指定してください。"));
+ }
+
+ var before = sample.Status;
+ var beforeRetentionEnd = sample.RetainUntil is { } until && businessDate.Today <= until;
+ sample.Status = request.Status;
+ sample.ClosedOn = businessDate.Today;
+ sample.ClosedByUserId = userId;
+ sample.Note = request.Note ?? sample.Note;
+ await db.SaveChangesAsync(ct);
+
+ await auditLogger.LogAsync("Inventory", "SampleClose", nameof(SampleStorage), id.ToString(),
+ detail: new
+ {
+ before,
+ after = sample.Status,
+ reason = request.Note,
+ beforeRetentionEnd,
+ }, ct: ct);
+ return Outcome.Ok(sample);
+ }
+}
diff --git a/src/MesApp.Api/Services/ShippingService.cs b/src/MesApp.Api/Services/ShippingService.cs
index dd6c5f3..b161dc0 100644
--- a/src/MesApp.Api/Services/ShippingService.cs
+++ b/src/MesApp.Api/Services/ShippingService.cs
@@ -23,9 +23,6 @@ public sealed class ShippingService(
IBusinessDateService businessDate,
IAuditLogger auditLogger)
{
- /// 自動採番の出荷番号の接頭辞(手入力の番号と衝突させないため、手入力では使わせない)
- public const string AutoShippingNoPrefix = "SH";
-
///
/// 出荷指示の作成(D-40-20-01)。
/// shippingNo を渡すとその番号で登録する(CSV取込で後続の出荷判定・出荷実行から指示を指すため。空なら自動採番)
@@ -47,11 +44,10 @@ public async Task> CreateAsync(
{
shippingNo = await numbering.NextShippingNoAsync(ct);
}
- else if (shippingNo.StartsWith(AutoShippingNoPrefix, StringComparison.OrdinalIgnoreCase))
+ else if (NumberingService.IsAutoNumberFormat(shippingNo, NumberingService.ShippingNoPrefix))
{
- // 自動採番の連番は採番テーブルで管理しており、同じ形式の手入力番号があると後で衝突する
return Outcome.Invalid(
- ApiText.T("出荷番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", shippingNo, AutoShippingNoPrefix));
+ ApiText.T("出荷番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", shippingNo, NumberingService.ShippingNoPrefix));
}
else if (await db.ShippingOrders.AnyAsync(s => s.ShippingNo == shippingNo, ct))
{
diff --git a/src/MesApp.Api/Services/ShopFloorReportService.cs b/src/MesApp.Api/Services/ShopFloorReportService.cs
index 21a609f..74e27c0 100644
--- a/src/MesApp.Api/Services/ShopFloorReportService.cs
+++ b/src/MesApp.Api/Services/ShopFloorReportService.cs
@@ -10,8 +10,10 @@ namespace MesApp.Api.Services;
///
/// 現場からの記録のうち作業指示の実行に属さないもの:作業時間(B-30-30-02、F-30-20-01)、
-/// 製造トラブル報告(B-40-10-06、B-60-10)、設備の稼働・停止の記録(B-40-20、E-20-10-01)。
-/// 単票API(WorkTimeRecordsController・TroubleReportsController・EquipmentLogsController)と
+/// 製造トラブル報告(B-40-10-06、B-60-10)、設備の稼働・停止の記録(B-40-20、E-20-10-01)、
+/// 治工具の利用実績(E-60-20-01)。
+/// 単票API(WorkTimeRecordsController・TroubleReportsController・EquipmentLogsController・
+/// ToolUsagesController)と
/// 実績CSV取込の両方から呼ぶ。
/// 保存と監査ログまで行う。トランザクションは呼び出し側が張る。
///
@@ -130,4 +132,45 @@ await auditLogger.LogAsync("Equipment", "Log", nameof(EquipmentLog), log.Id.ToSt
}, ct: ct);
return Outcome.Ok(log);
}
+
+ ///
+ /// 治工具の利用実績(E-60-20-01。寿命の累計に使う)。記録はロールで絞らない(現場作業者も記録できる)。
+ /// recordedAt は省略すると登録時刻(CSV取込で過去の利用実績を入れるときだけ指定する)
+ ///
+ public async Task> AddToolUsageAsync(
+ ToolUsageRequest request, DateTimeOffset? recordedAt, string? userId, CancellationToken ct)
+ {
+ var tool = await db.Tools.AsNoTracking().FirstOrDefaultAsync(t => t.Id == request.ToolId, ct);
+ if (tool is null || !tool.IsActive)
+ {
+ return Outcome.Invalid(ApiText.T("存在しない(または無効な)治工具IDです。"));
+ }
+ if (request.WorkOrderId is int workOrderId
+ && !await db.WorkOrders.AnyAsync(w => w.Id == workOrderId, ct))
+ {
+ return Outcome.Invalid(ApiText.T("存在しない作業指示IDです。"));
+ }
+ if (request.UsageCount <= 0 && (request.UsageHours is null or <= 0))
+ {
+ return Outcome.Invalid(ApiText.T("使用回数または使用時間のどちらかを記録してください。"));
+ }
+
+ var usage = new ToolUsage
+ {
+ ToolId = request.ToolId,
+ WorkOrderId = request.WorkOrderId,
+ UsageCount = request.UsageCount,
+ UsageHours = request.UsageHours,
+ RecordedByUserId = userId,
+ };
+ if (recordedAt is { } at)
+ {
+ usage.RecordedAt = at;
+ }
+ db.ToolUsages.Add(usage);
+ await db.SaveChangesAsync(ct);
+ await auditLogger.LogAsync("Equipment", "ToolUsage", nameof(ToolUsage), usage.Id.ToString(),
+ detail: new { toolId = usage.ToolId, count = usage.UsageCount, hours = usage.UsageHours }, ct: ct);
+ return Outcome.Ok(usage);
+ }
}
diff --git a/src/MesApp.Api/Services/StocktakeService.cs b/src/MesApp.Api/Services/StocktakeService.cs
index 1d416fd..b8b41b2 100644
--- a/src/MesApp.Api/Services/StocktakeService.cs
+++ b/src/MesApp.Api/Services/StocktakeService.cs
@@ -18,9 +18,12 @@ public sealed class StocktakeService(
NumberingService numbering,
IAuditLogger auditLogger)
{
- /// 棚卸指示の作成(D-50-10-01。現在庫(数量>0)のスナップショットを明細化)
+ ///
+ /// 棚卸指示の作成(D-50-10-01。現在庫(数量>0)のスナップショットを明細化)。
+ /// stocktakeNo を渡すとその番号で登録する(CSV取込で後続の行から棚卸を指すため。空なら自動採番)
+ ///
public async Task> CreateAsync(
- StocktakeCreateRequest request, string? userId, CancellationToken ct)
+ StocktakeCreateRequest request, string? stocktakeNo, string? userId, CancellationToken ct)
{
if (request.TargetLocationId is int locationId
&& !await db.Locations.AnyAsync(l => l.Id == locationId, ct))
@@ -39,9 +42,23 @@ public async Task> CreateAsync(
return Outcome.Invalid(ApiText.T("対象在庫がありません。"));
}
+ if (string.IsNullOrWhiteSpace(stocktakeNo))
+ {
+ stocktakeNo = await numbering.NextStocktakeNoAsync(ct);
+ }
+ else if (NumberingService.IsAutoNumberFormat(stocktakeNo, NumberingService.StocktakeNoPrefix))
+ {
+ return Outcome.Invalid(
+ ApiText.T("棚卸番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", stocktakeNo, NumberingService.StocktakeNoPrefix));
+ }
+ else if (await db.Stocktakes.AnyAsync(x => x.StocktakeNo == stocktakeNo, ct))
+ {
+ return Outcome.Conflict(ApiText.T("棚卸番号 '{0}' は既に存在します。", stocktakeNo));
+ }
+
var stocktake = new Stocktake
{
- StocktakeNo = await numbering.NextStocktakeNoAsync(ct),
+ StocktakeNo = stocktakeNo,
TargetLocationId = request.TargetLocationId,
CreatedByUserId = userId,
Lines = stocks.Select(s => new StocktakeLine
diff --git a/src/MesApp.Api/Services/TransferOrderService.cs b/src/MesApp.Api/Services/TransferOrderService.cs
new file mode 100644
index 0000000..b362c09
--- /dev/null
+++ b/src/MesApp.Api/Services/TransferOrderService.cs
@@ -0,0 +1,102 @@
+using MesApp.Core.Localization;
+using MesApp.Api.Localization;
+using MesApp.Core.Abstractions;
+using MesApp.Core.Contracts.Execution;
+using MesApp.Core.Entities;
+using MesApp.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+
+namespace MesApp.Api.Services;
+
+///
+/// 搬送・移動指示の作成・実行・取消(B-50-10-01〜02、D-30-10-04)。
+/// 単票API(TransferOrdersController)と実績CSV取込の両方から呼ぶ。搬送指示の状態はここでだけ変更する。
+/// 保存と監査ログまで行う。トランザクションは呼び出し側が張る。
+///
+public sealed class TransferOrderService(MesAppDbContext db, InventoryService inventory, IAuditLogger auditLogger)
+{
+ /// 搬送指示の作成(B-50-10-01)
+ public async Task> CreateAsync(
+ TransferOrderRequest request, string? userId, CancellationToken ct)
+ {
+ if (!await db.Lots.AnyAsync(l => l.Id == request.LotId, ct))
+ {
+ return Outcome.Invalid(ApiText.T("存在しないロットIDです。"));
+ }
+ if (request.FromLocationId == request.ToLocationId)
+ {
+ return Outcome.Invalid(ApiText.T("移動元と移動先が同一です。"));
+ }
+ var locationIds = new[] { request.FromLocationId, request.ToLocationId };
+ if (await db.Locations.CountAsync(l => locationIds.Contains(l.Id) && l.IsActive, ct) != 2)
+ {
+ return Outcome.Invalid(ApiText.T("存在しない(または無効な)ロケーションが含まれています。"));
+ }
+
+ var order = new TransferOrder
+ {
+ LotId = request.LotId,
+ Quantity = request.Quantity,
+ FromLocationId = request.FromLocationId,
+ ToLocationId = request.ToLocationId,
+ CreatedByUserId = userId,
+ };
+ db.TransferOrders.Add(order);
+ await db.SaveChangesAsync(ct);
+ await auditLogger.LogAsync("Inventory", "TransferCreate", nameof(TransferOrder), order.Id.ToString(),
+ detail: new { lotId = order.LotId, quantity = order.Quantity,
+ from = order.FromLocationId, to = order.ToLocationId }, ct: ct);
+ return Outcome.Ok(order);
+ }
+
+ /// 移動実行(B-50-10-02。在庫を移動して完了にする)
+ public async Task> ExecuteAsync(int id, string? userId, CancellationToken ct)
+ {
+ var order = await db.TransferOrders.Include(t => t.Lot).FirstOrDefaultAsync(t => t.Id == id, ct);
+ if (order is null)
+ {
+ return Outcome.NotFound(ApiText.T("搬送指示が見つかりません。"));
+ }
+ if (order.Status != TransferOrderStatus.Instructed)
+ {
+ return Outcome.Conflict(ApiText.T("状態 '{0}' の搬送指示は実行できません。", EnumLabels.Of(order.Status)));
+ }
+
+ try
+ {
+ await inventory.MoveAsync(order.Lot!, order.FromLocationId, order.ToLocationId, order.Quantity,
+ InventoryTransactionType.Move, userId, note: $"搬送指示 #{order.Id}", ct: ct);
+ }
+ catch (InventoryException ex)
+ {
+ return Outcome.Invalid(ex.Message);
+ }
+
+ order.Status = TransferOrderStatus.Completed;
+ order.ExecutedByUserId = userId;
+ order.ExecutedAt = DateTimeOffset.UtcNow;
+ await db.SaveChangesAsync(ct);
+ await auditLogger.LogAsync("Inventory", "Transfer", nameof(TransferOrder), id.ToString(), ct: ct);
+ return Outcome.Ok(order);
+ }
+
+ /// 搬送指示の取消(指示のままのものだけ)
+ public async Task> CancelAsync(int id, CancellationToken ct)
+ {
+ var order = await db.TransferOrders.FindAsync([id], ct);
+ if (order is null)
+ {
+ return Outcome.NotFound(ApiText.T("搬送指示が見つかりません。"));
+ }
+ if (order.Status != TransferOrderStatus.Instructed)
+ {
+ return Outcome.Conflict(ApiText.T("状態 '{0}' の搬送指示は取消できません。", EnumLabels.Of(order.Status)));
+ }
+ var before = order.Status;
+ order.Status = TransferOrderStatus.Canceled;
+ await db.SaveChangesAsync(ct);
+ await auditLogger.LogAsync("Inventory", "TransferCancel", nameof(TransferOrder), id.ToString(),
+ detail: new { before, after = order.Status }, ct: ct);
+ return Outcome.Ok(order);
+ }
+}
diff --git a/src/MesApp.Client.Web/Pages/Maintenance/MaintenanceProceduresTab.razor b/src/MesApp.Client.Web/Pages/Maintenance/MaintenanceProceduresTab.razor
index 5f092f6..4950dc3 100644
--- a/src/MesApp.Client.Web/Pages/Maintenance/MaintenanceProceduresTab.razor
+++ b/src/MesApp.Client.Web/Pages/Maintenance/MaintenanceProceduresTab.razor
@@ -54,6 +54,10 @@
+@* 取込の権限は単票の登録と同じ MaintenanceManage(Spec.md 3.8) *@
+