diff --git a/Spec.md b/Spec.md index cd5262d..684d3ec 100644 --- a/Spec.md +++ b/Spec.md @@ -123,6 +123,10 @@ 改訂122: 2026-09-23(5.7節「指図展開時のマスタ固定」に、予定材料の標準不良率による割り増しと消費工程の固定(改訂117・118)を追記した。あわせてマスタCSV・実績CSVの列の案内(`AlternativeGroup`・`IsAlternative`・`Backflush`)、サンプルの説明、利用者ガイド(帳票・用語・マスタのCSV・部材投入)を改訂117〜121に合わせた) 改訂123: 2026-09-23(工程管理項目の自由記述(`Routing.ControlItems`)の長さの上限を、工順・作業指示・API・マスタCSVで1000文字に揃えた。工順が1000文字、展開先の作業指示が500文字だったため、501文字以上を書いた工順は PostgreSQL / SQL Server で指図の展開に失敗していた(SQLiteは長さを守らせないため表に出なかった)。上限を超える記述は工順の登録で拒否する) 改訂124: 2026-09-23(工程管理項目を工順(BOP)の工程ごとに紐付けるようにした(`RoutingControlItem`。I-30-20-10)。これまでは項目側の対象品目/対象工程に「または」で合致する項目を展開時に自動選択していたため、品目を指定した項目がその品目の全工程の作業指示に載り、品目と工程の組合せや、同じ工程が2回ある工順の工程ごとの条件を表せなかった。改訂48の「工順に明示的な紐付けを持たせない」方針を改め、`ControlItem` から対象品目・対象工程を削除した(作業手順書と同じく紐付けは工順側だけに持つ)。紐付けられるのは有効な項目だけで、紐付け中の項目は単票APIでもCSV取込でも無効化できない(`MasterDeactivationPolicy`)。API は `RoutingStepRequest.ControlItemIds`、CSV は工程管理項目から `TargetProductCode`・`TargetProcessCode` を外し、工順に `ControlItemCodes`(セミコロン区切り)を追加。`Routing.ControlItems` の自由記述は補足として残す。5.1節・5.7節に反映) +改訂125: 2026-09-23(検査基準の自動選択を「対象品目 **または** 対象工程に合う」から「**かつ**で合う(空の方は問わない)」に改めた。品目Yの加熱工程の基準が品目Xの加熱工程の検査に載り、品目Xの別工程の基準も載っていた。工順(BOP)には紐付けない(基準は品質管理が品目・工程単位で策定するもの(C-10-10-02)で、受入・完成品検査は工程を持たないため)。あわせて、対象工程は工程内検査の基準だけに許し、受入・完成品・サンプル検査の基準は対象品目を必須に、工程内検査の基準は対象を少なくとも一方必須に、基準値は規格値の範囲内にした。検査項目を指定して発行する場合(API・実績CSV)も同じ条件で確かめる。判定は `InspectionItemPolicy` に集約し、単票APIとマスタCSV・実績CSVで共通にする。サンプルCSVの完成品検査基準から工程を外した。5.1節・5.7節に反映) +改訂126: 2026-09-23(検査指示がロットの保留を外す抜け道を塞いだ。ロットを拘束する検査指示(サンプル検査以外)は、ロットが正常か検査待ちのときだけ発行でき、再検査に限り不良ロットにも発行できる。同じロットに判定前の検査指示を重ねて発行しない。取消はロットを一律に正常へ戻していたのを、その指示が拘束する前のステータス(状態履歴の最初の拘束)へ戻すようにした。これまでは保留・不良のロットに検査指示を出して取り消すだけで正常になり、判定前の指示が2件あると先に合格した方でロットが使えるようになっていた。判定は `InspectionLotPolicy`。5.7節に反映) +改訂127: 2026-09-23(検査の判定を厳格にした。測定値と規格値で合否が決まる項目で、手で指定した合否が自動判定と食い違えば拒否する(これまでは手の指定が優先され、規格外の測定値でも「合格」と指定すれば合格になった)。規格外の品を使うなら不適合の特採で処置する。実績の訂正も同じ条件で合否を決める(訂正は合否を直接書き換えていた)。サンプリング数を総合判定の下限とし、その数以上のサンプルの実績がそろうまで判定できない(これまでは参考値で、1件でも判定できた)。判定は `InspectionJudgmentPolicy`。サンプルCSVの検査基準のサンプリング数を実績サンプルに合わせた。5.7節に反映) +改訂128: 2026-09-23(再検査(C-20-50-01)を、そのロットの直近の判定済みの検査をやり直すものとし、検査項目を引き継ぐようにした(版はいまの基準)。種別で完成品の基準を選んでいたため、受入ロットの再検査は基準が見つからず発行できなかった。判定済みの検査が無いロットには発行しない。あわせて、取消済みの作業指示への工程内検査の発行、作業指示と別品目のロットの指定、取消済みの検査の実績の訂正を拒否する。5.7節に反映) 参考: みんなのMES(min-MES) https://min-mes.com/ / OSS: https://github.com/mihatama/open-mes-project --- @@ -418,7 +422,7 @@ DBはバックエンド(MesApp.Api)のみが保持し、既定はSQLiteと | Tool(治工具マスタ) | 治工具コード、名称、種別(型/切削工具/検査工具等)、寿命閾値(使用回数/時間)、状態(使用可能/使用中/メンテナンス中/廃棄。**使用中は引当(`ToolIssue`)から決まり、マスタ編集・CSV取込では付けも外しもできない**。引当中の治工具は使用可能にできず、メンテナンス中・廃棄への変更はできる)(E-60) | | Location(ロケーション) | ロケーションコード、倉庫/エリア区分(部材倉庫/工程内/製品倉庫/出荷場)、**所属する作業区**、棚番(D-50-20-01) | | ControlItem(工程管理項目) | 工程管理項目コード、名称、単位、**指示値**、許容下限/上限、版数、有効フラグ。対象品目・対象工程は持たず、工順(BOP)の工程から紐付ける(`RoutingControlItem`)。温度・回転数など製造時に記録すべき条件の定義(B-30-30-04)。検査項目と同じ形にしているのは、「指示値と実績値」の関係が「検査パラメータと検査結果」と同じ構造で、判定の考え方も揃うため | -| InspectionItem(検査項目・基準) | 検査項目コード、対象品目/工程、検査種別(受入/工程内/完成品/サンプル)、規格値(上限/下限/基準値)、検査方法、サンプリング数、版数(C-10-10) | +| InspectionItem(検査項目・基準) | 検査項目コード、対象品目/工程、検査種別(受入/工程内/完成品/サンプル)、規格値(上限/下限/基準値)、検査方法、サンプリング数、版数(C-10-10)。対象品目/工程は**両方を「かつ」で組み合わせ**、空の方は問わない(5.7節「検査基準の対象」) | | InspectionDevice(検査機・測定器) | 検査機コード、名称、製造番号、設置場所、最終校正日、**次回校正期限**、校正周期(日)、備考、状態(C-20-50-03)。校正期限切れ・無効化された機器は検査実績に使えない | | InspectionDeviceCalibration(校正実施記録) | 検査機ID、校正日、次回期限、結果・所見、実施者(C-20-50-03)。現在値の上書きだけでは「いつ誰がどの結果で校正したか」が残らないため履歴に残す | | Checklist(チェックリストマスタ) | チェックリストコード、名称、適用区分(共通/工程/品目/段取り/保全/HSE)、項目一覧(B-30-10、G-20/G-30) | @@ -493,6 +497,11 @@ DBはバックエンド(MesApp.Api)のみが保持し、既定はSQLiteと - 指図の階層:`ManufacturingOrder`(品目×数量)→ 工順(`Routing`)に基づく展開 → `WorkOrder`(品目×工程)。リワーク指図は`ManufacturingOrder.指図区分`+`元指図ID`で表現する。 - トレーサビリティの連鎖:産出ロット(`ProductionRecord.産出ロットID`)⇔ 作業指示 ⇔ 部材投入(`MaterialConsumption.投入ロットID`。バックフラッシュ・手動とも同一エンティティに記録)⇔ 部材ロット、のリレーションでロット単位の前方・後方追跡を実現する。ロットの分割・統合・振替は`LotGenealogy`(由来元⇔由来先の1関係=1レコード)で系譜を保持し、統合のように由来元が複数ある場合も前方・後方の双方向で辿れるようにする。 - 検査の対象:`InspectionOrder`は受入検査ではロット、工程内検査では作業指示(+ロット)、完成品検査では産出ロットを対象とする。判定結果はロットの在庫ステータス(検査待ち→正常/不良)へ反映する。 + - **検査とロットの在庫ステータス**:ロットを拘束する検査指示(サンプル検査以外)は、ロットが**正常か検査待ち**のときだけ発行できる。再検査(C-20-50-01)に限り**不良**のロットにも発行できる。保留・廃棄予定のロットに発行できると、発行→取消や発行→合格で保留が外れてしまう(保留の解除は不適合の処置で行う) + - 同じロットに**判定前の検査指示を重ねて発行しない**。先に合格した方でロットが正常になり、もう一方の判定を待たずに使えてしまうため + - **再検査(C-20-50-01)は、そのロットの直近の判定済みの検査をやり直すもの**とし、検査項目をその検査から引き継ぐ(版はいまの基準)。種別で基準を選ぶと、受入ロットの再検査に完成品の基準を探して発行できなかった。判定済みの検査が無いロットには発行しない + - 工程内検査は取消済みの作業指示には発行しない。対象ロットを指定するなら作業指示の品目のロットに限る。取消済みの検査の実績は訂正しない + - 取消は、ロットを**その指示が拘束する前のステータスへ戻す**(状態履歴の最初の拘束から引く)。一律に正常へ戻すと、不良ロットの再検査を取り消しただけで不良が解除される。判定は `Api/Policies/InspectionLotPolicy` - **出来高の数量区分**:生産実績は 良品数・不良数 に加えて、**不良数の内訳**として廃棄数・再作業待ち数を持つ(`廃棄数+再作業待ち数 ≦ 不良数`。残りは判定待ち・保留中の数量)。内訳としたのは、総生産数(=良品数+不良数)を基準にするバックフラッシュ(B-40-10-09)と不良率(C-40-10-03)の意味を変えないため - 在庫計上(B-40-10-02)の対象は従来どおり良品数のみで、内訳は在庫に影響しない - 再作業待ち数はリワーク指図(B-70-10)との数量突合に使う @@ -560,6 +569,15 @@ DBはバックエンド(MesApp.Api)のみが保持し、既定はSQLiteと - **実績訂正の扱い**:生産実績・検査実績は現在値を上書きして訂正する(一覧・集計・在庫計上は常に最新値を見ればよい)。**訂正前の値は訂正履歴(`ProductionRecordCorrection` / `InspectionResultCorrection`)へ1訂正=1レコードで残す**。「現在状態は業務を進めるため、履歴は後から説明するため」という分離をロット状態履歴(5.3節)と同じ方針で適用する - 訂正履歴は**業務履歴**であり、製造記録・トレース画面・検査成績書から参照する。監査ログ(`AuditLog`。7.6節)は操作の記録であり、用途が異なるため両方に残す - 訂正できる状態と権限は従来どおり(承認済みの検査は訂正不可。訂正は生産管理・品質管理ロールのみ)。訂正そのものへの承認フローは初期リリースでは行わない[将来拡張] +- **検査基準の対象**:検査基準は品質管理が品目単位・工程単位で策定するもの(C-10-10-02)で、工程管理項目(5.7節「製造条件の指示値と実績値」)と違い**工順(BOP)には紐付けない**。受入・完成品・サンプル検査は工程を持たず、購入品には工順そのものが無いため。また検査指示は人が対象を選んで発行するもので、全作業指示へ自動で載るものではない + - 対象品目・対象工程は、それぞれ**空なら条件にしない・指定があれば一致を求める**とし、両方を**「かつ」**で組み合わせる(`Api/Policies/InspectionItemPolicy`)。「または」で選ぶと、品目Yの加熱工程の基準が品目Xの加熱工程の検査に載り、品目Xの別工程の基準も載る + - **対象工程を持てるのは工程内検査の基準だけ**。受入・完成品・サンプル検査の基準は対象品目を必須とする(工程を持たない検査に工程付きの基準を登録しても永久に選ばれない)。工程内検査の基準は対象品目か対象工程の少なくとも一方を持つ(全品目・全工程に載る基準は意図せず広がるため作らせない)。基準値は規格値の範囲の内側に置く + - 検査項目を指定して発行する場合(API・実績CSV)も同じ条件で確かめ、合わない基準(無効・種別違い・対象違い)は拒否する + - 同じ工程が工順に2回ある場合、工程ごとに別の基準を持たせることはできない。必要になれば基準に工程順序を持たせて拡張する[将来拡張] +- **検査の判定**:合否は指示発行時点のスナップショットの規格値で決める(`Api/Policies/InspectionJudgmentPolicy`) + - 測定値と規格値(下限・上限のどちらか)があれば自動判定し、**手で指定した合否が自動判定と食い違えば拒否する**。規格外の品を使うなら不適合の特採(C-30)で処置する。判定を手で上書きできると、規格外品が合格の記録で出ていく + - **実績の訂正も同じ条件で合否を決める**(訂正だけ別の条件にすると、訂正が規格外品を合格にする抜け道になる) + - **サンプリング数は総合判定の下限**。サンプリング数を持つ項目は、その数以上のサンプル(サンプル番号の種類)の実績がそろうまで判定できない - **検査基準の版と過去実績**:検査項目マスタ(`InspectionItem`)は改訂され規格値が上書きされる(版数のみ加算)。最新マスタで過去ロットを判定・印字すると当時の判定根拠を再現できないため、**検査指示の発行時点の基準を`InspectionOrderItem`へ写して保持**する。合否の自動判定・詳細画面・検査成績書はこのスナップショットを使う(改訂後に成績書を再出力しても、当時の規格値と版数が印字される)。マスタを参照するのは新規指示の作成時だけとする。 - 不適合の連鎖:`NonconformanceReport`の対応指示から、リワークは`ManufacturingOrder`(リワーク区分)、保留・廃棄は`Lot.在庫ステータス`変更、特採は承認記録付きで次工程進行(在庫ステータスを正常へ戻す)、へそれぞれ接続する(C-30、H-30-20)。**在庫ステータスの変更は契機(在庫操作/検査/不適合/受入)を問わず`LotStatusHistory`へ遷移を残す**(誰が・いつ・なぜ止め、どの判断で解除したかを後から説明できるようにするため)。 - 治工具寿命:`ToolUsage`の累計と`Tool.寿命閾値`の比較で交換・廃棄通知を行う(E-60-20-02)。 diff --git a/docs-dev/CodeMap.md b/docs-dev/CodeMap.md index e0a63eb..d575e5a 100644 --- a/docs-dev/CodeMap.md +++ b/docs-dev/CodeMap.md @@ -46,8 +46,8 @@ |:--|:--|:--|:--|:--|:--| | 工程管理項目マスタ(指示値・許容範囲。版数付き) | 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-10-10 | `InspectionItemsController.cs` `api/inspection-items` | Masters.cs: InspectionItem | `/masters` `Masters/InspectionItemsTab.razor` | `Tests/MasterTests.cs` | -| 検査指示・実績・判定・成績書 | C-20 | `InspectionOrdersController.cs` `api/inspection-orders`(発行・実績・判定の処理は `Api/Services/InspectionService.cs`。実績登録時に**検査機の校正期限を判定**:`InspectionDeviceCalibrationPolicy`) | `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-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`。発行できるロットの状態・判定前の指示の重複は `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-40-10 | `QualityAnalysisController.cs` `api/quality/summary` | Execution.cs: ProductionDefect(不良理由別の内訳)
ProductionRecord.`ShiftId`(**記録時に固定**。集計で引き直さない) | `/quality-analysis` `QualityAnalysis.razor`
`Web/Shared/BarMeter.razor` | `Tests/QualityTests.cs` | | 不良理由マスタ | C-40-10-01 | `DefectReasonsController.cs` `api/defect-reasons` | Masters.cs: DefectReason | `/masters` `Masters/DefectReasonsTab.razor` | `Tests/MasterTests.cs` / `MasterCsvTests.cs` | diff --git a/docs/masters.md b/docs/masters.md index d8a0c88..f3181d1 100644 --- a/docs/masters.md +++ b/docs/masters.md @@ -180,11 +180,11 @@ MBOMは、実績入力の**バックフラッシュ**(部材の自動消費) |---|---| | 検査項目コード / 名称 | 基本情報 | | 検査種別 | 受入検査/工程内検査/完成品検査/サンプル検査 | -| 対象品目 / 対象工程 | 適用範囲。未指定なら広く適用されます | +| 対象品目 / 対象工程 | 適用範囲。指定した方は一致が必要で、両方指定すると「その品目のその工程」だけに使われます。未指定の方は問いません。対象工程を指定できるのは工程内検査だけで、受入・完成品・サンプル検査は対象品目が必須です | | 規格値 下限 / 上限 | 測定値の合否をこの範囲で自動判定します | | 基準値 | 目標値(表示用) | | 検査方法 | 目視、ノギスなどの記述 | -| サンプリング数 | 参考値 | +| サンプリング数 | 判定に必要な最低サンプル数。この数だけサンプル番号の違う実績がそろうまで総合判定できません(多く測るのは構いません) |

上限・下限のどちらも設定していない項目は自動判定できません。 diff --git a/docs/quality.md b/docs/quality.md index 285c3c4..061799e 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -36,6 +36,21 @@ next_title: 品質保証 発行すると検査指示番号が採番され(`IN20260501-0001`)、**対象ロットの在庫ステータスが「検査待ち」になります**。 +

+

次のロットには検査指示を発行できません(サンプル検査を除く。サンプル検査はロットを拘束しません)。

+ +

工程内検査は、取り消した作業指示には発行できません。対象ロットを指定する場合は、その作業指示の品目のロットに限ります。

+
+ +
+

再検査は、そのロットの直近の判定済みの検査をやり直すものです。検査項目は元の検査から引き継がれ、規格値はいまの基準の版が使われます。 +判定済みの検査が無いロットには発行できません。

+
+

検査項目は自動で選ばれます。 マスタ管理 → 検査項目・基準 に登録された項目のうち、 検査種別・対象品目・対象工程が合致するものが検査指示に組み込まれます。 @@ -70,7 +85,8 @@ next_title: 品質保証

  1. 測定値を入力します。規格値の範囲と照合して、合否が自動判定されます。
  2. 数値でない検査は定性記録に文字で記入します。
  3. -
  4. 自動判定できない項目(規格値が未設定の項目)は、判定(手動)で合格/不合格を選びます。
  5. +
  6. 自動判定できない項目(規格値が未設定の項目)は、判定(手動)で合格/不合格を選びます。 +測定値と規格値で合否が決まる項目は、手動の判定で変えることはできません(規格外の品を使う場合は、不適合の特採で処置します)。
  7. 検査実績を登録を押します。
@@ -84,7 +100,7 @@ next_title: 品質保証 ### 総合判定する -**全検査項目の実績を登録してから** 判定します。 +**全検査項目の実績を登録してから** 判定します。サンプリング数が決まっている項目は、その数だけサンプル番号の違う実績がそろっている必要があります。
  1. 必要ならグレードを入力します(検査結果で出荷先や等級が変わる製品向け。任意)。
  2. @@ -115,7 +131,8 @@ next_title: 品質保証 誤って発行した検査指示は **検査指示を取消** で取り消せます(承認済み・取消済みは不可)。
    -

    取消すと、その指示のために検査待ちで拘束していたロットが解放され、正常に戻ります。 +

    取消すと、その指示のために検査待ちで拘束していたロットが、発行する前のステータスに戻ります。 +通常は正常に戻りますが、不良ロットの再検査を取り消した場合は不良に戻ります。 判定が済んでいる指示を取り消しても、ロットの現在のステータスはそのままです。

    @@ -132,7 +149,7 @@ next_title: 品質保証 訂正内容と理由は監査ログに記録されます。
    -

    承認済みの検査は訂正できません。 また、判定済みの検査を訂正した場合は、 +

    承認済み・取消済みの検査は訂正できません。 また、判定済みの検査を訂正した場合は、 もう一度総合判定をやり直す必要があります。

    diff --git a/samples/master-csv/12_inspection-items.csv b/samples/master-csv/12_inspection-items.csv index 484a7b8..79898de 100644 --- a/samples/master-csv/12_inspection-items.csv +++ b/samples/master-csv/12_inspection-items.csv @@ -1,7 +1,7 @@ Code,Name,TargetProductCode,TargetProcessCode,Type,LowerLimit,UpperLimit,StandardValue,Method,SamplingCount,IsActive -INS-01,ハウジング内径,SF-2000,PR-10,InProcess,49.95,50.05,50,ノギス,5,true -INS-02,ハウジング外観,SF-2000,PR-10,InProcess,,,,目視,3,true -INS-03,鋳造品 受入外観,RM-3001,,Receiving,,,,目視,10,true -INS-04,吐出量測定,FG-1000,PR-40,FinalProduct,19,21,20,流量計,1,true -INS-05,気密試験,FG-1000,PR-40,FinalProduct,,,,加圧0.5MPa 60秒保持,1,true +INS-01,ハウジング内径,SF-2000,PR-10,InProcess,49.95,50.05,50,ノギス,2,true +INS-02,ハウジング外観,SF-2000,PR-10,InProcess,,,,目視,1,true +INS-03,鋳造品 受入外観,RM-3001,,Receiving,,,,目視,1,true +INS-04,吐出量測定,FG-1000,,FinalProduct,19,21,20,流量計,1,true +INS-05,気密試験,FG-1000,,FinalProduct,,,,加圧0.5MPa 60秒保持,1,true INS-06,保管サンプル外観,FG-1000,,Sample,,,,目視,1,true diff --git a/src/MesApp.Api/Controllers/InspectionItemsController.cs b/src/MesApp.Api/Controllers/InspectionItemsController.cs index 629167f..b1d51d2 100644 --- a/src/MesApp.Api/Controllers/InspectionItemsController.cs +++ b/src/MesApp.Api/Controllers/InspectionItemsController.cs @@ -1,4 +1,5 @@ using MesApp.Api.Localization; +using MesApp.Api.Policies; using MesApp.Core.Abstractions; using MesApp.Core.Contracts.Masters; using MesApp.Core.Entities; @@ -12,6 +13,7 @@ namespace MesApp.Api.Controllers; /// /// 検査項目・基準マスタ(Spec.md 5.1 InspectionItem。C-10-10)。 /// 基準の更新(C-10-10-03)では版数を自動インクリメントする。 +/// 対象品目・対象工程の意味と組み合わせの制約は 。 /// [ApiController] [Route("api/inspection-items")] @@ -134,11 +136,9 @@ public Task Deactivate(int id, CancellationToken ct) => { return ApiText.T("存在しない対象工程IDです。"); } - if (request.LowerLimit is not null && request.UpperLimit is not null && request.LowerLimit > request.UpperLimit) - { - return ApiText.T("規格値の下限が上限を超えています。"); - } - return null; + // 判定条件はマスタCSV取込と共通(片方だけ通る状態を作らない。Spec.md 7.4) + return InspectionItemPolicy.CheckDefinition(request.Type, request.TargetProductId, request.TargetProcessId, + request.LowerLimit, request.UpperLimit, request.StandardValue); } /// diff --git a/src/MesApp.Api/Localization/ApiText.en.resx b/src/MesApp.Api/Localization/ApiText.en.resx index aed7dcb..b0be851 100644 --- a/src/MesApp.Api/Localization/ApiText.en.resx +++ b/src/MesApp.Api/Localization/ApiText.en.resx @@ -2016,4 +2016,43 @@ Control item '{0}' cannot be deactivated because it is linked from the routing of item {1}. + + The standard value is outside the specification limits. + + + Specify a target item or a target process for an in-process inspection criterion. + + + Only in-process inspection criteria can specify a target process. + + + Specify a target item for receiving, final product, and sample inspection criteria. + + + Inspection item '{0}' cannot be used for this inspection (it is inactive, has a different inspection type, or its target item or process does not match). + + + Lot '{0}' already has inspection order {1} awaiting judgment. Judge or cancel it before issuing another. + + + An inspection order cannot be issued for lot '{1}' with stock status "{0}" (defective lots allow reinspection only; release holds through nonconformance disposition). + + + The measured value {1} for inspection item '{0}' is judged "{2}" by the specification limits. The judgment cannot be changed manually (use a nonconformance concession to use out-of-spec products). + + + Inspection item '{0}' requires {1} samples but has results for {2}. Register results for at least the sampling count before judging. + + + An in-process inspection cannot be issued for canceled work order {0}. + + + Lot '{0}' is not a lot of the item of work order {1}. + + + Lot '{0}' cannot be reinspected because it has no judged inspection. + + + A canceled inspection cannot be corrected. + diff --git a/src/MesApp.Api/Policies/InspectionItemPolicy.cs b/src/MesApp.Api/Policies/InspectionItemPolicy.cs new file mode 100644 index 0000000..2f029db --- /dev/null +++ b/src/MesApp.Api/Policies/InspectionItemPolicy.cs @@ -0,0 +1,75 @@ +using System.Linq.Expressions; +using MesApp.Api.Localization; +using MesApp.Core.Entities; + +namespace MesApp.Api.Policies; + +/// +/// 検査基準()の対象の決め方(Spec.md 5.7 検査基準の対象。C-10-10-02、C-20)。 +/// +/// 基準は品質管理が「品目単位・工程単位」で策定するもの(C-10-10-02)で、工順(BOP)には紐付けない +/// (受入・完成品・サンプル検査は工程を持たず、購入品には工順そのものが無い)。 +/// 対象品目・対象工程はそれぞれ「空なら条件にしない、指定があれば一致を求める」とし、**両方を『かつ』で組み合わせる**。 +/// 「または」で選ぶと、品目Yの加熱工程の基準が品目Xの加熱工程の検査に載り、品目Xの別工程の基準も載ってしまう。 +/// +/// 登録(単票APIとマスタCSV)と検査指示の発行(単票APIと実績CSV)の両方から呼ぶ。 +/// +public static class InspectionItemPolicy +{ + /// + /// 基準の定義の整合。問題があれば日本語の理由を返す。 + /// 対象工程を持てるのは工程内検査の基準だけ(他の検査は工程を持たないため、工程を指定した基準は永久に選ばれない) + /// + public static string? CheckDefinition( + InspectionType type, int? targetProductId, int? targetProcessId, + decimal? lowerLimit, decimal? upperLimit, decimal? standardValue) + { + if (lowerLimit is not null && upperLimit is not null && lowerLimit > upperLimit) + { + return ApiText.T("規格値の下限が上限を超えています。"); + } + // 基準値が規格の外にあると、基準どおりの品が不合格と判定される + if (standardValue is { } standard + && ((lowerLimit is { } lower && standard < lower) || (upperLimit is { } upper && standard > upper))) + { + return ApiText.T("基準値が規格値の範囲の外にあります。"); + } + if (type == InspectionType.InProcess) + { + // 対象を持たない基準はどの検査にも選ばれない(全品目・全工程に載せる基準は意図せず広がるため作らせない) + return targetProductId is null && targetProcessId is null + ? ApiText.T("工程内検査の基準には対象品目か対象工程を指定してください。") + : null; + } + if (targetProcessId is not null) + { + return ApiText.T("対象工程を指定できるのは工程内検査の基準だけです。"); + } + return targetProductId is null + ? ApiText.T("受入・完成品・サンプル検査の基準には対象品目を指定してください。") + : null; + } + + /// + /// 検査指示の種別に対応する基準の種別。再検査は元の検査の基準を引き継ぐため、 + /// 呼び出し側(InspectionService)が元の検査の基準の種別に置き換える + /// + public static InspectionType ItemTypeOf(InspectionOrderType orderType) => orderType switch + { + InspectionOrderType.Receiving => InspectionType.Receiving, + InspectionOrderType.InProcess => InspectionType.InProcess, + InspectionOrderType.Sample => InspectionType.Sample, + _ => InspectionType.FinalProduct, + }; + + /// + /// その検査(種別・品目・工程)に使える基準。有効で、種別が同じで、対象品目・対象工程が「かつ」で合うもの。 + /// 工程を持たない検査( が null)では、対象工程を持つ基準は選ばれない + /// + public static Expression> Applicable( + InspectionType type, int productId, int? processId) => + i => i.IsActive && i.Type == type + && (i.TargetProductId == null || i.TargetProductId == productId) + && (i.TargetProcessId == null || i.TargetProcessId == processId) + && (i.TargetProductId != null || i.TargetProcessId != null); +} diff --git a/src/MesApp.Api/Policies/InspectionJudgmentPolicy.cs b/src/MesApp.Api/Policies/InspectionJudgmentPolicy.cs new file mode 100644 index 0000000..537be48 --- /dev/null +++ b/src/MesApp.Api/Policies/InspectionJudgmentPolicy.cs @@ -0,0 +1,67 @@ +using MesApp.Api.Localization; +using MesApp.Core.Entities; +using MesApp.Core.Localization; + +namespace MesApp.Api.Policies; + +/// +/// 検査実績の合否と総合判定の条件(Spec.md 5.7 検査の判定。C-20-10-04)。 +/// 基準はマスタの現在値ではなく、指示発行時点のスナップショット()を使う。 +/// 実績の登録と訂正の両方から呼ぶ(訂正だけ別の条件にすると、訂正が規格外品を合格にする抜け道になる)。 +/// +public static class InspectionJudgmentPolicy +{ + /// + /// 1件の実績の合否を決める。決められなければ日本語の理由を返す。 + /// + /// 測定値があり規格値(下限・上限のどちらか)があれば、規格値で自動判定する(下限≦測定値≦上限)。 + /// 手で指定した合否が自動判定と食い違えば拒否する。規格外の品を使うなら不適合の特採で処置する(C-30) + /// 自動判定できない項目(規格値が無い・測定値が無い)は、手で指定した合否を使う(指定が無ければ拒否) + /// + /// + public static (InspectionJudgment? Judgment, string? Error) Resolve( + InspectionOrderItem item, decimal? measuredValue, InspectionJudgment? explicitJudgment) + { + if (measuredValue is { } measured && (item.LowerLimit is not null || item.UpperLimit is not null)) + { + var pass = (item.LowerLimit is null || measured >= item.LowerLimit) + && (item.UpperLimit is null || measured <= item.UpperLimit); + var auto = pass ? InspectionJudgment.Pass : InspectionJudgment.Fail; + if (explicitJudgment is { } manual && manual != auto) + { + return (null, ApiText.T( + "検査項目 '{0}' の測定値 {1} は規格値による判定が「{2}」です。判定を手で変えることはできません(規格外の品を使う場合は不適合の特採で処置してください)。", + item.ItemCode, measured, EnumLabels.Of(auto))); + } + return (auto, null); + } + return explicitJudgment is null + ? (null, ApiText.T("検査項目 '{0}' は規格値による自動判定ができません。judgmentを指定してください。", item.ItemCode)) + : (explicitJudgment, null); + } + + /// + /// 総合判定の前提。全項目に実績があり、サンプリング数を持つ項目はその数以上のサンプル(サンプル番号の種類)が + /// そろっていること。満たさなければ日本語の理由を返す。サンプリング数は下限として扱う(多く測るのは妨げない) + /// + public static string? CheckReadyToJudge( + IReadOnlyCollection items, IReadOnlyCollection results) + { + var withoutResult = items.Count(i => results.All(r => r.InspectionItemId != i.InspectionItemId)); + if (withoutResult > 0) + { + return ApiText.T("実績未登録の検査項目が {0} 件あります。全項目の実績登録後に判定してください。", withoutResult); + } + foreach (var item in items.Where(i => i.SamplingCount is > 0).OrderBy(i => i.ItemCode, StringComparer.Ordinal)) + { + var samples = results.Where(r => r.InspectionItemId == item.InspectionItemId) + .Select(r => r.SampleNo).Distinct().Count(); + if (samples < item.SamplingCount) + { + return ApiText.T("検査項目 '{0}' はサンプリング数 {1} に対して実績が {2} サンプルです。サンプリング数以上の実績を登録してから判定してください。", + item.ItemCode, item.SamplingCount!.Value, samples); + } + } + return null; + } +} diff --git a/src/MesApp.Api/Policies/InspectionLotPolicy.cs b/src/MesApp.Api/Policies/InspectionLotPolicy.cs new file mode 100644 index 0000000..e8268e5 --- /dev/null +++ b/src/MesApp.Api/Policies/InspectionLotPolicy.cs @@ -0,0 +1,37 @@ +using MesApp.Api.Localization; +using MesApp.Core.Entities; +using MesApp.Core.Localization; + +namespace MesApp.Api.Policies; + +/// +/// 検査指示が対象ロットを検査待ちで拘束してよいかの判定(Spec.md 5.7 検査とロットの在庫ステータス。C-20)。 +/// +/// 検査指示の発行はロットを検査待ちにし、判定で正常/不良へ、取消で発行前のステータスへ戻す。 +/// 保留・廃棄予定のロットへ発行できると、発行→取消や発行→合格で保留が外れてしまう(保留の解除は不適合の処置で行う)。 +/// 判定前の指示が2件あると、先に合格した方でロットが正常になり、もう一方の判定を待たずに使えてしまう。 +/// +/// 単票API(InspectionOrdersController)と実績CSV取込の両方が InspectionService 経由でここを通る。 +/// +public static class InspectionLotPolicy +{ + /// + /// ロットを拘束する検査指示(サンプル検査以外)を発行できるか。できなければ日本語の理由を返す。 + /// 正常・検査待ちのロットに発行できる。再検査(C-20-50-01)に限り、不合格で不良になったロットにも発行できる + /// + /// そのロットを拘束している判定前の検査指示の番号(無ければ null) + public static string? CheckIssuable(Lot lot, InspectionOrderType type, string? pendingOrderNo) + { + if (pendingOrderNo is not null) + { + return ApiText.T("ロット '{0}' には判定前の検査指示 {1} があります。判定するか取り消してから発行してください。", + lot.LotNumber, pendingOrderNo); + } + var issuable = lot.StockStatus is LotStockStatus.Normal or LotStockStatus.AwaitingInspection + || (type == InspectionOrderType.Reinspection && lot.StockStatus == LotStockStatus.Defective); + return issuable + ? null + : ApiText.T("在庫ステータスが「{0}」のロット '{1}' には検査指示を発行できません(不良ロットは再検査のみ。保留は不適合の処置で解除してください)。", + EnumLabels.Of(lot.StockStatus), lot.LotNumber); + } +} diff --git a/src/MesApp.Api/Services/InspectionService.cs b/src/MesApp.Api/Services/InspectionService.cs index daace3a..0f52afb 100644 --- a/src/MesApp.Api/Services/InspectionService.cs +++ b/src/MesApp.Api/Services/InspectionService.cs @@ -24,7 +24,8 @@ public sealed class InspectionService( { /// /// 検査指示(依頼)の発行(C-20-10-02 ほか)。検査項目未指定時は種別・対象品目/工程に - /// 合致する有効な検査基準を自動選択する。対象ロットは検査待ちになる(サンプル検査を除く)。 + /// 合致する有効な検査基準を自動選択する()。 + /// 検査項目を指定した場合も同じ条件に合わない基準は拒否する。対象ロットは検査待ちになる(サンプル検査を除く)。 /// public async Task> CreateAsync( InspectionOrderCreateRequest request, string? userId, CancellationToken ct) @@ -43,6 +44,10 @@ public async Task> CreateAsync( { return Outcome.Invalid(ApiText.T("存在しない作業指示IDです。")); } + if (workOrder.Status == WorkOrderStatus.Canceled) + { + return Outcome.Conflict(ApiText.T("取消済みの作業指示 {0} には工程内検査を発行できません。", workOrder.WorkOrderNo)); + } } else if (request.TargetLotId is null) { @@ -56,9 +61,55 @@ public async Task> CreateAsync( { return Outcome.Invalid(ApiText.T("存在しないロットIDです。")); } + // 工程内検査で指定したロットは、その作業指示の品目のロットであること(別品目のロットを拘束・判定させない) + if (workOrder is not null && lot.ProductId != workOrder.ProductId) + { + return Outcome.Invalid( + ApiText.T("ロット '{0}' は作業指示 {1} の品目のロットではありません。", lot.LotNumber, workOrder.WorkOrderNo)); + } + // ロットを検査待ちで拘束する検査は、拘束してよい状態のときだけ発行する(サンプル検査は拘束しない) + if (request.Type != InspectionOrderType.Sample) + { + var pending = await db.InspectionOrders.AsNoTracking() + .Where(o => o.TargetLotId == lot.Id && o.Type != InspectionOrderType.Sample + && (o.Status == InspectionOrderStatus.Instructed || o.Status == InspectionOrderStatus.InProgress)) + .Select(o => o.OrderNo) + .FirstOrDefaultAsync(ct); + if (InspectionLotPolicy.CheckIssuable(lot, request.Type, pending) is { } blocked) + { + return Outcome.Conflict(blocked); + } + } } - // 検査項目セットの決定 + // 検査項目セットの決定:種別+対象品目/工程に「かつ」で合う有効な基準(C-10-10) + var itemType = InspectionItemPolicy.ItemTypeOf(request.Type); + var processId = workOrder?.ProcessId; + List? inheritedItemIds = null; + if (request.Type == InspectionOrderType.Reinspection) + { + // 再検査(C-20-50-01)は、そのロットの直近の判定済みの検査をやり直すもの。基準はその検査の項目を引き継ぎ、 + // 版はいまの基準を使う(受入ロットの再検査に完成品の基準を当てない) + var source = await db.InspectionOrders.AsNoTracking() + .Include(o => o.Items) + .Include(o => o.TargetWorkOrder) + .Where(o => o.TargetLotId == lot!.Id && o.Type != InspectionOrderType.Sample + && (o.Status == InspectionOrderStatus.Judged || o.Status == InspectionOrderStatus.Approved)) + .OrderByDescending(o => o.Id) + .FirstOrDefaultAsync(ct); + if (source is null) + { + return Outcome.Invalid( + ApiText.T("ロット '{0}' には判定済みの検査が無いため再検査できません。", lot!.LotNumber)); + } + inheritedItemIds = [.. source.Items.Select(i => i.InspectionItemId)]; + itemType = await db.InspectionItems.AsNoTracking() + .Where(i => inheritedItemIds.Contains(i.Id)) + .Select(i => i.Type) + .FirstAsync(ct); + processId = source.TargetWorkOrder?.ProcessId; + } + var applicable = InspectionItemPolicy.Applicable(itemType, lot?.ProductId ?? workOrder!.ProductId, processId); List items; if (request.ItemIds is { Count: > 0 }) { @@ -70,24 +121,22 @@ public async Task> CreateAsync( { return Outcome.Invalid(ApiText.T("存在しない検査項目IDが含まれています。")); } + // 指定した基準も自動選択と同じ条件で確かめる(無効な基準や他品目の基準で判定させない) + var isApplicable = applicable.Compile(); + if (items.FirstOrDefault(i => !isApplicable(i)) is { } unusable) + { + return Outcome.Invalid(ApiText.T( + "検査項目 '{0}' はこの検査に使えません(無効、検査種別が違う、または対象の品目・工程が合いません)。", unusable.Code)); + } } else { - // 自動選択:種別(再検査は完成品基準)+対象品目/工程に合致する有効な基準(C-10-10) - var itemType = request.Type switch + var candidates = db.InspectionItems.AsNoTracking().Where(applicable); + if (inheritedItemIds is not null) { - InspectionOrderType.Receiving => InspectionType.Receiving, - InspectionOrderType.InProcess => InspectionType.InProcess, - InspectionOrderType.Sample => InspectionType.Sample, - _ => InspectionType.FinalProduct, - }; - var productId = lot?.ProductId ?? workOrder!.ProductId; - var processId = workOrder?.ProcessId; - items = await db.InspectionItems.AsNoTracking() - .Where(i => i.IsActive && i.Type == itemType && - (i.TargetProductId == productId || - (processId != null && i.TargetProcessId == processId))) - .ToListAsync(ct); + candidates = candidates.Where(i => inheritedItemIds.Contains(i.Id)); + } + items = await candidates.ToListAsync(ct); if (items.Count == 0) { return Outcome.Invalid( @@ -179,12 +228,11 @@ public async Task> AddResultsAsync( { return Outcome.Invalid(ApiText.T("検査項目ID {0} はこの検査指示の対象ではありません。", request.InspectionItemId)); } - // 判定は指示発行時点の規格値(スナップショット)で行う - var judgment = Judge(item, request.MeasuredValue, request.Judgment); - if (judgment is null) + // 判定は指示発行時点の規格値(スナップショット)で行う。規格値で決まる合否は手で変えられない + var (judgment, invalid) = InspectionJudgmentPolicy.Resolve(item, request.MeasuredValue, request.Judgment); + if (invalid is not null) { - return Outcome.Invalid( - ApiText.T("検査項目 '{0}' は規格値による自動判定ができません。judgmentを指定してください。", item.ItemCode)); + return Outcome.Invalid(invalid); } db.InspectionResults.Add(new InspectionResult { @@ -193,7 +241,7 @@ public async Task> AddResultsAsync( SampleNo = request.SampleNo ?? 1, MeasuredValue = request.MeasuredValue, TextValue = request.TextValue, - Judgment = judgment.Value, + Judgment = judgment!.Value, InspectedByUserId = userId, InspectionDeviceId = request.InspectionDeviceId, }); @@ -226,13 +274,10 @@ public async Task> JudgeAsync( return Outcome.Conflict(ApiText.T("状態 '{0}' の検査指示は判定できません。", EnumLabels.Of(order.Status))); } - var itemsWithoutResult = order.Items - .Where(i => order.Results.All(r => r.InspectionItemId != i.InspectionItemId)) - .ToList(); - if (itemsWithoutResult.Count > 0) + // 全項目の実績と、サンプリング数(下限)だけのサンプルがそろってから判定する + if (InspectionJudgmentPolicy.CheckReadyToJudge(order.Items, order.Results) is { } notReady) { - return Outcome.Invalid( - ApiText.T("実績未登録の検査項目が {0} 件あります。全項目の実績登録後に判定してください。", itemsWithoutResult.Count)); + return Outcome.Invalid(notReady); } var pass = order.Results.All(r => r.Judgment == InspectionJudgment.Pass); @@ -281,7 +326,7 @@ await auditLogger.LogAsync("Quality", "InspectionJudge", nameof(InspectionOrder) public async Task> CorrectResultAsync( int orderId, int resultId, InspectionResultCorrectionRequest request, string? userId, CancellationToken ct) { - var order = await db.InspectionOrders.Include(o => o.TargetLot) + var order = await db.InspectionOrders.Include(o => o.TargetLot).Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId, ct); if (order is null) { @@ -291,12 +336,23 @@ public async Task> CorrectResultAsync( { return Outcome.Conflict(ApiText.T("承認済みの検査は訂正できません。")); } + if (order.Status == InspectionOrderStatus.Canceled) + { + return Outcome.Conflict(ApiText.T("取消済みの検査は訂正できません。")); + } var result = await db.InspectionResults .FirstOrDefaultAsync(r => r.Id == resultId && r.InspectionOrderId == orderId, ct); if (result is null) { return Outcome.NotFound(ApiText.T("検査実績が存在しません。")); } + // 訂正後の合否も登録と同じ条件で決める(訂正が規格外品を合格にする抜け道にならないように) + var item = order.Items.Single(i => i.InspectionItemId == result.InspectionItemId); + var (judgment, invalid) = InspectionJudgmentPolicy.Resolve(item, request.MeasuredValue, request.Judgment); + if (invalid is not null) + { + return Outcome.Invalid(invalid); + } var before = new { value = result.MeasuredValue, text = result.TextValue, judgment = result.Judgment }; @@ -311,14 +367,14 @@ public async Task> CorrectResultAsync( BeforeJudgment = result.Judgment, AfterMeasuredValue = request.MeasuredValue, AfterTextValue = request.TextValue, - AfterJudgment = request.Judgment, + AfterJudgment = judgment!.Value, Reason = request.Reason, CorrectedByUserId = userId, }); result.MeasuredValue = request.MeasuredValue; result.TextValue = request.TextValue; - result.Judgment = request.Judgment; + result.Judgment = judgment.Value; result.CorrectionNote = string.IsNullOrEmpty(result.CorrectionNote) ? request.Reason : $"{result.CorrectionNote}\n{request.Reason}"; @@ -347,7 +403,7 @@ await auditLogger.LogAsync("Quality", "InspectionCorrect", nameof(InspectionResu { value = request.MeasuredValue, text = request.TextValue, - judgment = request.Judgment, + judgment = judgment.Value, }, reason = request.Reason, }, ct: ct); @@ -375,7 +431,10 @@ await auditLogger.LogAsync("Quality", "InspectionApprove", nameof(InspectionOrde return Outcome.Ok(order); } - /// 検査指示の取消。承認済み・取消済み以外を取り消し、検査待ちで拘束していたロットを解放する + /// + /// 検査指示の取消。承認済み・取消済み以外を取り消し、検査待ちで拘束していたロットを**発行前のステータスへ戻す**。 + /// 一律に正常へ戻すと、不良ロットの再検査を取り消しただけで不良が解除されてしまう + /// public async Task> CancelAsync(int orderId, string? userId, CancellationToken ct) { var order = await db.InspectionOrders.Include(o => o.TargetLot) @@ -389,34 +448,25 @@ public async Task> CancelAsync(int orderId, string? use return Outcome.Conflict(ApiText.T("状態 '{0}' の検査指示は取消できません。", EnumLabels.Of(order.Status))); } order.Status = InspectionOrderStatus.Canceled; - // 検査待ちで拘束していたロットを解放する - if (order.TargetLot is { StockStatus: LotStockStatus.AwaitingInspection }) + // 検査待ちで拘束していたロットを、この指示が拘束する前のステータスへ戻す(状態履歴の最初の拘束から引く) + if (order.TargetLot is { StockStatus: LotStockStatus.AwaitingInspection } lot) { - lotStatus.ChangeStatus(order.TargetLot, LotStockStatus.Normal, LotStatusChangeSource.Inspection, - $"検査指示 {order.OrderNo} の取消による拘束解除", userId, inspectionOrderId: order.Id); + var before = await db.LotStatusHistories.AsNoTracking() + .Where(h => h.LotId == lot.Id && h.InspectionOrderId == order.Id + && h.ToStatus == LotStockStatus.AwaitingInspection) + .OrderBy(h => h.Id) + .Select(h => (LotStockStatus?)h.FromStatus) + .FirstOrDefaultAsync(ct); + // 履歴が無いのは発行前から検査待ちだった場合。そのままにする + if (before is not null) + { + lotStatus.ChangeStatus(lot, before.Value, LotStatusChangeSource.Inspection, + $"検査指示 {order.OrderNo} の取消による拘束解除", userId, inspectionOrderId: order.Id); + } } await db.SaveChangesAsync(ct); await auditLogger.LogAsync("Quality", "InspectionCancel", nameof(InspectionOrder), orderId.ToString(), ct: ct); return Outcome.Ok(order); } - /// - /// 規格値との照合による自動判定(下限≦測定値≦上限)。判定不能ならnull。 - /// 基準はマスタの現在値ではなく、指示発行時点のスナップショットを使う - /// - private static InspectionJudgment? Judge( - InspectionOrderItem item, decimal? measuredValue, InspectionJudgment? explicitJudgment) - { - if (explicitJudgment is not null) - { - return explicitJudgment; - } - if (measuredValue is null || (item.LowerLimit is null && item.UpperLimit is null)) - { - return null; - } - var pass = (item.LowerLimit is null || measuredValue >= item.LowerLimit) - && (item.UpperLimit is null || measuredValue <= item.UpperLimit); - return pass ? InspectionJudgment.Pass : InspectionJudgment.Fail; - } } diff --git a/src/MesApp.Api/Services/MasterCsvService.Import.cs b/src/MesApp.Api/Services/MasterCsvService.Import.cs index 76e7386..01ce040 100644 --- a/src/MesApp.Api/Services/MasterCsvService.Import.cs +++ b/src/MesApp.Api/Services/MasterCsvService.Import.cs @@ -680,9 +680,11 @@ private async Task ImportInspectionItemsAsync( var method = reader.Text("Method", item.Method, 200); var sampling = reader.IntOrNull("SamplingCount", item.SamplingCount, 0); var isActive = reader.Bool("IsActive", item.IsActive); - if (lower is not null && upper is not null && lower > upper) + // 判定条件は単票APIと共通(片方だけ通る状態を作らない。Spec.md 7.4) + if (!reader.Failed + && InspectionItemPolicy.CheckDefinition(type, productId, processId, lower, upper, standard) is { } invalid) { - reader.Fail(ApiText.T("規格値の下限が上限を超えています。")); + reader.Fail(invalid); } if (reader.Failed) { diff --git a/tests/MesApp.Api.Tests/MasterCsvTests.cs b/tests/MesApp.Api.Tests/MasterCsvTests.cs index 9e1ce58..fe90ee9 100644 --- a/tests/MesApp.Api.Tests/MasterCsvTests.cs +++ b/tests/MesApp.Api.Tests/MasterCsvTests.cs @@ -333,9 +333,13 @@ public class MasterCsvTests using var factory = new ApiFactory(); using var client = await TestAuth.CreateAdminClientAsync(factory); + Assert.True((await ImportAsync(client, "processes", """ + Code,Name,Category + PR-01,加工,InHouse + """)).Succeeded); Assert.True((await ImportAsync(client, "inspection-items", """ - Code,Name,Type,LowerLimit,UpperLimit,StandardValue,Method,SamplingCount - INS-01,外径測定,InProcess,9.5,10.5,10,ノギス,5 + Code,Name,TargetProcessCode,Type,LowerLimit,UpperLimit,StandardValue,Method,SamplingCount + INS-01,外径測定,PR-01,InProcess,9.5,10.5,10,ノギス,5 """)).Succeeded); // 名称だけの変更では版数は上がらない @@ -354,13 +358,19 @@ public class MasterCsvTests items = await client.GetFromJsonAsync>("/api/inspection-items"); Assert.Equal(2, items!.Single().Version); - // 下限が上限を超える行はエラー + // 単票APIと同じ条件で弾く:下限>上限・基準値が規格外・工程内検査以外で工程を指定・対象なし var invalid = await ImportAsync(client, "inspection-items", """ - Code,Name,LowerLimit,UpperLimit - INS-02,逆転,10,1 + Code,Name,TargetProcessCode,Type,LowerLimit,UpperLimit,StandardValue + INS-02,逆転,PR-01,InProcess,10,1, + INS-03,基準値外,PR-01,InProcess,1,2,3 + INS-04,受入に工程,PR-01,Receiving,,, + INS-05,対象なし,,InProcess,,, """); Assert.False(invalid.Succeeded); - Assert.Contains(invalid.Errors, e => e.Message.Contains("下限")); + Assert.Contains(invalid.Errors, e => e.Line == 2 && e.Message.Contains("下限")); + Assert.Contains(invalid.Errors, e => e.Line == 3 && e.Message.Contains("基準値")); + Assert.Contains(invalid.Errors, e => e.Line == 4 && e.Message.Contains("工程内検査の基準だけ")); + Assert.Contains(invalid.Errors, e => e.Line == 5 && e.Message.Contains("対象品目か対象工程")); } [Fact] diff --git a/tests/MesApp.Api.Tests/MasterTests.cs b/tests/MesApp.Api.Tests/MasterTests.cs index 449d40f..e3bcfa9 100644 --- a/tests/MesApp.Api.Tests/MasterTests.cs +++ b/tests/MesApp.Api.Tests/MasterTests.cs @@ -310,8 +310,9 @@ public class MasterTests new LocationRequest("LOC-01", LocationAreaType.MaterialWarehouse, "A-1-1")); Assert.Equal(HttpStatusCode.Created, location.StatusCode); + var process = await CreateProcessAsync(client, "PR-01", "加工"); var inspection = await client.PostAsJsonAsync("/api/inspection-items", - new InspectionItemRequest("INS-01", "外径測定", null, null, InspectionType.InProcess, + new InspectionItemRequest("INS-01", "外径測定", null, process.Id, InspectionType.InProcess, 9.5m, 10.5m, 10m, "ノギス", 5)); Assert.Equal(HttpStatusCode.Created, inspection.StatusCode); @@ -325,7 +326,7 @@ public class MasterTests // 検査項目の更新で版数が上がる(C-10-10-03) var inspectionBody = await inspection.Content.ReadFromJsonAsync(); var updated = await client.PutAsJsonAsync($"/api/inspection-items/{inspectionBody!.Id}", - new InspectionItemRequest("INS-01", "外径測定", null, null, InspectionType.InProcess, + new InspectionItemRequest("INS-01", "外径測定", null, process.Id, InspectionType.InProcess, 9.0m, 11.0m, 10m, "マイクロメータ", 10)); var updatedBody = await updated.Content.ReadFromJsonAsync(); Assert.Equal(2, updatedBody!.Version); diff --git a/tests/MesApp.Api.Tests/QualityTests.cs b/tests/MesApp.Api.Tests/QualityTests.cs index 7ed40a7..edce2dd 100644 --- a/tests/MesApp.Api.Tests/QualityTests.cs +++ b/tests/MesApp.Api.Tests/QualityTests.cs @@ -96,6 +96,169 @@ private static async Task CreateFinalInspectionItemAsync return (await response.Content.ReadFromJsonAsync())!; } + [Fact] + public async Task 工程内検査の基準は対象品目と対象工程の両方が合うものだけが選ばれる() + { + using var factory = new ApiFactory(); + using var admin = await TestAuth.CreateAdminClientAsync(factory); + var ctx = await Phase3TestData.SetupAsync(admin); + var other = await MasterTests.CreateProductAsync(admin, "FG-99", "別の完成品", ProductType.Product); + var otherProcess = await MasterTests.CreateProcessAsync(admin, "PR-99", "別工程"); + async Task CreateItemAsync(string code, int? productId, int? processId) + { + var response = await admin.PostAsJsonAsync("/api/inspection-items", + new InspectionItemRequest(code, code, productId, processId, InspectionType.InProcess, + null, null, null, null, null)); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!.Id; + } + await CreateItemAsync("INS-A", ctx.ProductId, ctx.ProcessId); // この品目のこの工程 + await CreateItemAsync("INS-B", ctx.ProductId, null); // この品目のどの工程でも + await CreateItemAsync("INS-C", null, ctx.ProcessId); // この工程ならどの品目でも + var otherProductItem = await CreateItemAsync("INS-D", other.Id, ctx.ProcessId); // 別品目の同じ工程 + await CreateItemAsync("INS-E", ctx.ProductId, otherProcess.Id); // 同じ品目の別工程 + + var order = await Phase3TestData.CreateReleasedOrderAsync(admin, ctx.ProductId, 10m); + var workOrderId = order.WorkOrders[0].Id; + var created = await admin.PostAsJsonAsync("/api/inspection-orders", + new InspectionOrderCreateRequest(InspectionOrderType.InProcess, null, workOrderId, null, null)); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var inspection = await created.Content.ReadFromJsonAsync(); + Assert.Equal(["INS-A", "INS-B", "INS-C"], inspection!.Items.Select(i => i.Code).Order()); + + // 指定した基準も同じ条件で確かめる(別品目の基準では検査させない) + var manual = await admin.PostAsJsonAsync("/api/inspection-orders", + new InspectionOrderCreateRequest(InspectionOrderType.InProcess, null, workOrderId, [otherProductItem], null)); + Assert.Equal(HttpStatusCode.BadRequest, manual.StatusCode); + Assert.Contains("INS-D", await manual.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task 保留ロットや判定前の指示があるロットには発行できず再検査の取消で不良に戻る() + { + using var factory = new ApiFactory(); + using var admin = await TestAuth.CreateAdminClientAsync(factory); + var ctx = await Phase3TestData.SetupAsync(admin); + var item = await CreateFinalInspectionItemAsync(admin, ctx.ProductId); + var lot = await Phase3TestData.ReceiveAsync(admin, ctx.ProductId, 100m, ctx.ProductLocationId); + async Task IssueAsync(InspectionOrderType type) => + await admin.PostAsJsonAsync("/api/inspection-orders", + new InspectionOrderCreateRequest(type, lot.Id, null, null, null)); + async Task LotStatusAsync() => + (await admin.GetFromJsonAsync($"/api/inventory/lots/{lot.Id}"))!.StockStatus; + + // 保留中のロットには発行できない(発行→取消で保留が外れてしまう) + (await admin.PostAsJsonAsync("/api/inventory/status", + new Core.Contracts.Inventory.LotStatusRequest(lot.Id, LotStockStatus.OnHold, "調査中"))).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Conflict, (await IssueAsync(InspectionOrderType.FinalProduct)).StatusCode); + Assert.Equal(LotStockStatus.OnHold, await LotStatusAsync()); + (await admin.PostAsJsonAsync("/api/inventory/status", + new Core.Contracts.Inventory.LotStatusRequest(lot.Id, LotStockStatus.Normal, "調査完了"))).EnsureSuccessStatusCode(); + + // 判定前の指示があるロットには重ねて発行できない(先に合格した方でロットが正常になってしまう) + var first = await (await IssueAsync(InspectionOrderType.FinalProduct)).Content.ReadFromJsonAsync(); + var duplicated = await IssueAsync(InspectionOrderType.FinalProduct); + Assert.Equal(HttpStatusCode.Conflict, duplicated.StatusCode); + Assert.Contains(first!.OrderNo, await duplicated.Content.ReadAsStringAsync()); + // サンプル検査はロットを拘束しないので重ねてよい + (await admin.PostAsJsonAsync("/api/inspection-items", + new InspectionItemRequest("INS-S", "保管サンプル外観", ctx.ProductId, null, InspectionType.Sample, + null, null, null, "目視", 1))).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Created, (await IssueAsync(InspectionOrderType.Sample)).StatusCode); + + // 不合格で不良になったロットは再検査だけ発行でき、再検査を取り消すと不良に戻る(正常にしない) + await admin.PostAsJsonAsync($"/api/inspection-orders/{first.Id}/results", + new List { new(item.Id, 1, 12.0m, null, null) }); + (await admin.PostAsJsonAsync($"/api/inspection-orders/{first.Id}/judge", new InspectionJudgeRequest(null))) + .EnsureSuccessStatusCode(); + Assert.Equal(LotStockStatus.Defective, await LotStatusAsync()); + Assert.Equal(HttpStatusCode.Conflict, (await IssueAsync(InspectionOrderType.FinalProduct)).StatusCode); + var reinspection = await (await IssueAsync(InspectionOrderType.Reinspection)).Content.ReadFromJsonAsync(); + Assert.Equal(LotStockStatus.AwaitingInspection, await LotStatusAsync()); + (await admin.PostAsync($"/api/inspection-orders/{reinspection!.Id}/cancel", null)).EnsureSuccessStatusCode(); + Assert.Equal(LotStockStatus.Defective, await LotStatusAsync()); + } + + [Fact] + public async Task 規格値で決まる合否は手で変えられずサンプリング数がそろうまで判定できない() + { + using var factory = new ApiFactory(); + using var admin = await TestAuth.CreateAdminClientAsync(factory); + var ctx = await Phase3TestData.SetupAsync(admin); + var response = await admin.PostAsJsonAsync("/api/inspection-items", + new InspectionItemRequest("INS-01", "外径測定", ctx.ProductId, null, InspectionType.FinalProduct, + 9.5m, 10.5m, 10m, "ノギス", 2)); + var item = (await response.Content.ReadFromJsonAsync())!; + var lot = await Phase3TestData.ReceiveAsync(admin, ctx.ProductId, 100m, ctx.ProductLocationId); + var order = (await (await admin.PostAsJsonAsync("/api/inspection-orders", + new InspectionOrderCreateRequest(InspectionOrderType.FinalProduct, lot.Id, null, null, null))) + .Content.ReadFromJsonAsync())!; + + // 規格外の測定値を「合格」と指定しても通さない(規格外品を使うなら不適合の特採で処置する) + var overridden = await admin.PostAsJsonAsync($"/api/inspection-orders/{order.Id}/results", + new List { new(item.Id, 1, 12.0m, null, InspectionJudgment.Pass) }); + Assert.Equal(HttpStatusCode.BadRequest, overridden.StatusCode); + // 自動判定と同じ合否なら指定してもよい + var recorded = await (await admin.PostAsJsonAsync($"/api/inspection-orders/{order.Id}/results", + new List { new(item.Id, 1, 10.0m, null, InspectionJudgment.Pass) })) + .Content.ReadFromJsonAsync(); + + // サンプリング数2に対して1サンプルでは判定できない + var early = await admin.PostAsJsonAsync($"/api/inspection-orders/{order.Id}/judge", new InspectionJudgeRequest(null)); + Assert.Equal(HttpStatusCode.BadRequest, early.StatusCode); + Assert.Contains("サンプリング数", await early.Content.ReadAsStringAsync()); + + // 訂正も同じ条件:規格外の値へ直しながら合格にはできない + var resultId = recorded!.Results.Single().Id; + var badCorrection = await admin.PutAsJsonAsync($"/api/inspection-orders/{order.Id}/results/{resultId}", + new InspectionResultCorrectionRequest(11.0m, null, InspectionJudgment.Pass, "転記ミス")); + Assert.Equal(HttpStatusCode.BadRequest, badCorrection.StatusCode); + + (await admin.PostAsJsonAsync($"/api/inspection-orders/{order.Id}/results", + new List { new(item.Id, 2, 10.1m, null, null) })).EnsureSuccessStatusCode(); + var judged = await admin.PostAsJsonAsync($"/api/inspection-orders/{order.Id}/judge", new InspectionJudgeRequest(null)); + Assert.Equal(HttpStatusCode.OK, judged.StatusCode); + } + + [Fact] + public async Task 再検査は直近の判定済みの検査の基準を引き継ぎ取消済みの検査は訂正できない() + { + using var factory = new ApiFactory(); + using var admin = await TestAuth.CreateAdminClientAsync(factory); + var ctx = await Phase3TestData.SetupAsync(admin); + var receivingItem = (await (await admin.PostAsJsonAsync("/api/inspection-items", + new InspectionItemRequest("INS-R", "受入寸法", ctx.MaterialId, null, InspectionType.Receiving, + 9.5m, 10.5m, 10m, "ノギス", 1))).Content.ReadFromJsonAsync())!; + async Task IssueAsync(InspectionOrderType type, int lotId) => + await admin.PostAsJsonAsync("/api/inspection-orders", new InspectionOrderCreateRequest(type, lotId, null, null, null)); + + // 判定済みの検査が無いロットは再検査できない + var fresh = await Phase3TestData.ReceiveAsync(admin, ctx.MaterialId, 10m, ctx.MaterialLocationId); + Assert.Equal(HttpStatusCode.BadRequest, (await IssueAsync(InspectionOrderType.Reinspection, fresh.Id)).StatusCode); + + // 受入検査で不合格になった部材ロットの再検査は、受入の基準を引き継ぐ(完成品の基準を探さない) + var lot = await Phase3TestData.ReceiveAsync(admin, ctx.MaterialId, 100m, ctx.MaterialLocationId); + var first = (await (await IssueAsync(InspectionOrderType.Receiving, lot.Id)).Content.ReadFromJsonAsync())!; + await admin.PostAsJsonAsync($"/api/inspection-orders/{first.Id}/results", + new List { new(receivingItem.Id, 1, 12.0m, null, null) }); + (await admin.PostAsJsonAsync($"/api/inspection-orders/{first.Id}/judge", new InspectionJudgeRequest(null))) + .EnsureSuccessStatusCode(); + var reinspection = await IssueAsync(InspectionOrderType.Reinspection, lot.Id); + Assert.Equal(HttpStatusCode.Created, reinspection.StatusCode); + var reinspectionOrder = (await reinspection.Content.ReadFromJsonAsync())!; + Assert.Equal(["INS-R"], reinspectionOrder.Items.Select(i => i.Code)); + + // 取り消した検査の実績は訂正できない + var recorded = (await (await admin.PostAsJsonAsync($"/api/inspection-orders/{reinspectionOrder.Id}/results", + new List { new(receivingItem.Id, 1, 10.0m, null, null) })) + .Content.ReadFromJsonAsync())!; + (await admin.PostAsync($"/api/inspection-orders/{reinspectionOrder.Id}/cancel", null)).EnsureSuccessStatusCode(); + var correction = await admin.PutAsJsonAsync( + $"/api/inspection-orders/{reinspectionOrder.Id}/results/{recorded.Results.Single().Id}", + new InspectionResultCorrectionRequest(10.1m, null, InspectionJudgment.Pass, "転記ミス")); + Assert.Equal(HttpStatusCode.Conflict, correction.StatusCode); + } + [Fact] public async Task 検査指示を取消すと検査待ちのロットが解放され承認済みは取消せない() {