diff --git a/Spec.md b/Spec.md index 23c567b..7e576fb 100644 --- a/Spec.md +++ b/Spec.md @@ -114,6 +114,7 @@ 改訂113: 2026-09-23(3.8節:生産計画をマスタCSVの種別 `production-plans` として出力・取込できるようにした(PLAN-04)。キーは製造日・品目コード・工程コード・作業区コード(空欄も1つのキー)で上書きし、ZIPの一括入出力にも含める。参照先・数量・キーの判定を `ProductionPlanPolicy` に移し、単票APIとCSV取込の両方から呼ぶ。取込の権限は単票と同じ `ProductionManage`) 改訂114: 2026-09-23(3.8節・5.2節:同じ製造日・品目・工程で、作業区の範囲が重なる生産計画(作業区なしと作業区あり、上位の段と配下の段)を併存させないようにした。予実は作業区違いの計画を合算するため、全体の計画と内訳の計画が並ぶと二重に数えていた。兄弟の作業区どうしは併存できる。判定は `ProductionPlanPolicy` に置き、単票API(409)とCSV取込(既存との間・ファイル内とも行エラー)の両方から呼ぶ。既に重なって登録されている計画はそのまま残る) 改訂115: 2026-09-23(3.8節・7.2節:生産計画をマスタの一括ZIPの取込・出力から外した(改訂113で含めていた)。生産計画はマスタではなく日々増える業務データで、全期間がZIPに入り、マスタを別環境へ移すと過去の計画まで付いていったため。取込の仕組み(キーで上書き)は引き続きマスタCSVのものを使う。計画のCSV出力は全件をやめ、一覧と同じ絞り込みの `api/production-plans/csv` を追加した。あわせて、3か月分の実績サンプルと同じ期間の生産計画のサンプル `samples/production-plans/` を追加し(`scripts/New-SampleBulkActuals.ps1` が乱数を使わず生成。実績の内容は変えない)、リリースに `samples-production-plans.csv` として添付する。利用者向けガイド(生産管理・ロールと権限など)に生産計画・予実の操作を追記) +改訂116: 2026-09-23(3.8節:実績CSVに出荷指示 `shipping-orders`・出荷判定 `shipment-judgments`・出荷実行 `shipments` を追加した。サンプルの実績で出荷だけが画面操作で、製造から出荷までをCSVだけではたどれなかった。判定と出荷で取込の権限が違うため3種別に分ける。出荷判定の登録・承認を `ShipmentJudgmentsController` から `ShipmentJudgmentService` へ移し、単票APIとCSV取込の両方から呼ぶ。出荷指示は後続のCSVから指すため出荷番号の手入力を許す(`SH〜` は自動採番用として拒否)。サンプル `samples/actual-csv/13〜15` を追加) 参考: みんなのMES(min-MES) https://min-mes.com/ / OSS: https://github.com/mihatama/open-mes-project --- @@ -324,7 +325,7 @@ AGV/AMR連携(D-50-20-02)・リサイクル管理(D-50-30-02)・VMI/外 - 監査ログ(実績訂正・検査訂正・マスタ変更等の変更履歴。7.6節) - **マスタのCSV一括入出力**:各マスタ(品目/MBOM/工順/工程/作業区/設備/設備の保全部品/治工具/ロケーション/検査項目・基準/**検査機・測定器**/工程管理項目/チェックリスト/作業手順書/勤務シフト/スキル・資格/ユーザー/ユーザーのスキル資格)のCSV出力・テンプレート出力・一括取込。取込はコード(自然キー)による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`)。マスタCSVと同じく**1行でもエラーがあれば全件ロールバック**し、行番号付きのエラー一覧を返し、検証のみのモードを持つ。マスタCSVとの違いは次のとおり。①**取込は常に新規登録**で既存の実績を更新しない(実績の訂正は理由付きの訂正機能で行う)。②行ごとに**単票APIと同じ業務サービスを通す**(受入は `ReceivingService`、製造指図は `ManufacturingOrderService`、作業指示の実行記録は `WorkOrderExecutionService`、検査は `InspectionService`、作業時間・トラブル報告は `ShopFloorReportService`)——実績は在庫・ロット・状態履歴を連鎖して動かすため、行をエンティティへ直接写すと判定や監査ログが迂回される。③取込の権限は**種別ごとに単票APIと同じロールグループ**(受入は在庫・物流オペレーション)。④行は保存しながら進めるので、後の行は前の行の結果(登録済みロット番号の重複判定など)を前提にでき、検証のみも実際に登録してからロールバックする。他のマスタ・実績はIDではなくコード・番号で参照する。⑤**作業指示は「指図番号+工程順序」で指す**。自動採番の指図番号は取込前に分からないため、製造指図CSVに限り指図番号の手入力を許す(自動採番の形式 `MO〜` と重なる番号は、採番テーブルの連番と後で衝突するため拒否)。製造指図CSVは1行で登録・承認・工程展開まで進められるが、承認は省略できない(展開は承認済みの指図だけ)。⑥**実行記録の記録者は取込を実行したユーザー**とし、作業者を指定する列は持たない(他人名義の実績を作れると、記録者に意味が無くなる)。投入ロットはロット番号で、日時はオフセットの無い値を工場の時刻として読む。生産実績の直は開始日時で決まる。⑦**検査CSVは `InspectionKey` が同じ行を1件の検査指示にまとめる**。キーはファイルの中だけの名前でDBに残さない(検査指示番号は自動採番のままにし、後続のCSVから検査指示を指す必要も無いため)。まとまりに含まれる検査項目がその指示の対象になり、`Judge=true` なら実績に続けて総合判定する(ロットの状態反映・不合格時の不適合起票も単票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`)。マスタ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の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 75e8274..7f92e40 100644 --- a/docs-dev/CodeMap.md +++ b/docs-dev/CodeMap.md @@ -60,7 +60,7 @@ | 受入・受入ロット採番 | 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-40 / H-10-10 | `ShippingOrdersController.cs` `api/shipping-orders`
作成・出荷実行・取消は `Api/Services/ShippingService.cs` | Inventory.cs: ShippingOrder / ShippingLine | `/shipping` `Shipping.razor`
`/print/shipping/{id}` `Print/ShippingSlip.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-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` | @@ -93,7 +93,7 @@ | 業務 | MES No | API | エンティティ | 画面 | テスト | |:--|:--|:--|:--|:--|:--| -| 出荷判定(可/保留/特採・単段階承認) | H-10-10 | `ShipmentJudgmentsController.cs` `api/shipment-judgments` | Quality.cs: ShipmentJudgment | `/shipment-judgments` `ShipmentJudgments.razor`
`/print/shipment-judgment/{id}` `Print/ShipmentJudgmentDoc.razor` | `Tests/QualityTests.cs`
出荷ゲートは `InventoryTests.cs` | +| 出荷判定(可/保留/特採・単段階承認) | H-10-10 | `ShipmentJudgmentsController.cs` `api/shipment-judgments`
登録・承認は `Api/Services/ShipmentJudgmentService.cs`(単票APIと実績CSVの種別 `shipment-judgments` で共通) | Quality.cs: ShipmentJudgment | `/shipment-judgments` `ShipmentJudgments.razor`
`/print/shipment-judgment/{id}` `Print/ShipmentJudgmentDoc.razor` | `Tests/QualityTests.cs`
出荷ゲートは `InventoryTests.cs` | | ロットトレーサビリティ(前方・後方追跡) | H-30-10 | `TraceabilityController.cs` `api/traceability`(`/history` は製造・検査・在庫・状態・訂正・**設備稼働**の履歴を返す。製造行には作業者と**直**を並べる。文字列の組み立てはSQLに載せず取り出してから行う) | Production.cs: Lot
Execution.cs: MaterialConsumption
Maintenance.cs: EquipmentLog | `/traceability` `Traceability.razor`
`Web/Shared/TraceTree.razor` | `Tests/QualityTests.cs` | --- diff --git a/docs/assets/walkthrough/shipment-judgments.webp b/docs/assets/walkthrough/shipment-judgments.webp index 2c29c14..810a5cb 100644 Binary files a/docs/assets/walkthrough/shipment-judgments.webp and b/docs/assets/walkthrough/shipment-judgments.webp differ diff --git a/docs/assets/walkthrough/shipping.webp b/docs/assets/walkthrough/shipping.webp index e38a6b7..2a551f6 100644 Binary files a/docs/assets/walkthrough/shipping.webp and b/docs/assets/walkthrough/shipping.webp differ diff --git a/docs/masters.md b/docs/masters.md index 3346215..7ede9e7 100644 --- a/docs/masters.md +++ b/docs/masters.md @@ -305,20 +305,20 @@ CSVに登場しなかった親品目のMBOM・工順は変更されません。 ## 実績のCSV取込 {#actual-csv} -試験・移行・教育用に、**実績(受入・製造指図・段取り・チェックリスト・部材投入・生産実績・製造条件・検査・作業時間・トラブル報告・設備稼働記録)もCSVでまとめて登録できます**。 +試験・移行・教育用に、**実績(受入・製造指図・段取り・チェックリスト・部材投入・生産実績・製造条件・検査・作業時間・トラブル報告・設備稼働記録・出荷指示・出荷判定・出荷実行)もCSVでまとめて登録できます**。 メニューの **管理 → 実績CSV取込** を開き、種別ごとのパネルから取り込みます。 - 1件ずつ画面から登録したときと**同じチェック**を通ります(校正期限切れの検査機、理由の無い代替部品の投入、在庫不足などは取り込めません)。 在庫・ロットの状態・監査ログも同じように記録されます。 - **取込は常に新規登録**です。同じファイルを2回取り込むと、2回分の実績になります。 -- 作業指示は **指図番号+工程順序** で、投入・検査するロットは **ロット番号** で指します。 - 製造指図の指図番号と、後で投入するロットのロット番号は、CSVで指定しておいてください(自動採番の番号は取り込むまで分かりません)。 +- 作業指示は **指図番号+工程順序** で、投入・検査・出荷するロットは **ロット番号** で、出荷指示は **出荷番号** で指します。 + 製造指図の指図番号、後で投入するロットのロット番号、出荷指示の出荷番号は、CSVで指定しておいてください(自動採番の番号は取り込むまで分かりません)。 - **記録者は取り込んだユーザー**です。作業者を指定する列はありません。 -- 取り込める権限は、種別ごとに画面から登録するときと同じです(受入は物流、製造指図は生産管理、検査は品質管理 など)。 +- 取り込める権限は、種別ごとに画面から登録するときと同じです(受入・出荷指示・出荷実行は物流、製造指図は生産管理、検査は品質管理、出荷判定は品質保証 など)。 - 「1行でもエラーがあると全件取り消し」「検証のみ」はマスタのCSVと同じです。 - 画面の上の **ZIPで一括取込** で、複数のCSVを1回で取り込めます(ファイル名の順。1ファイルでもエラーがあればZIPの全ファイルを取り消し)。 -受入から検査まで一通りそろった**サンプル**が +受入から出荷まで一通りそろった**サンプル**が **[samples-actual-csv.zip](https://github.com/msmsrep/ParallelFactoryMES/releases/latest/download/samples-actual-csv.zip)** にあります (中身は [samples/actual-csv](https://github.com/msmsrep/ParallelFactoryMES/tree/main/samples/actual-csv))。 [サンプルCSV](#サンプルcsv) のマスタを取り込んだ後に、ファイル名の番号順に取り込んでください。 diff --git a/docs/walkthrough.md b/docs/walkthrough.md index b8e0855..239a7d6 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -13,7 +13,7 @@ next_title: マスタ管理

このページの内容は、そのまま手元で再現できます。 samples/master-csv と samples/actual-csv のCSVをそれぞれZIPにまとめ、 画面上部の ZIPで一括取込 から、マスタ、実績の順に取り込んでください。 -CSVに含まれない操作(完了承認・出荷など)は、最後の章にまとめてあります。

+CSVに含まれない操作(完了承認・検査承認など)は、最後の章にまとめてあります。

## 1. 全体の流れ {#overview} @@ -433,16 +433,16 @@ MBOMと工順の登録方法は、[マスタ管理の品目・MBOM・工順](mas
-出荷判定画面。SH20260922-0001 と FG1000-S001 を対象にした判定「可」が承認済で並んでいる -
出荷判定。対象の出荷指示とロットを選び、判定を登録して承認する
+出荷判定画面。SMP-SH-001 と FG1000-S001 を対象にした判定「可」が承認済、SMP-SH-002 の判定「保留」が未承認で並んでいる +
出荷判定。対象の出荷指示とロットを選び、判定を登録して承認する。サンプルでは SMP-SH-002 を「保留」のままにしてある
-出荷管理画面。サンプル商事株式会社あての出荷指示が完了になり、FG-1000 が指示数5・出荷済5になっている -
出荷管理。出荷を実行すると「出荷済」の数が増え、全明細がそろうと指示が「完了」になる
+出荷管理画面。サンプル商事株式会社あての SMP-SH-001 が完了(FG-1000 指示数5・出荷済5)、サンプル工業株式会社あての SMP-SH-002 が指示のままになっている +
出荷管理。出荷を実行すると「出荷済」の数が増え、全明細がそろうと指示が「完了」になる。SMP-SH-002 は判定が保留なので、出荷実行を押しても止まる
-出荷番号・判定書番号は採番した日で決まるので、写真の番号(SH20260922-0001 など)は取り込んだ日によって変わります。 +サンプルでは、出荷指示・出荷判定・出荷実行もCSV(`13`〜`15`)で入ります。出荷番号はCSVで指定した SMP-SH-001 ですが、判定書番号は採番した日で決まるので、取り込んだ日によって変わります。 詳しくは [物流・在庫管理の出荷管理](logistics.html) と [品質保証の出荷判定](assurance.html) をご覧ください。 --- @@ -503,7 +503,7 @@ FG1000-S001 の履歴タブ(製造・検査・在庫の記録)を開けば | 製造指図 | `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件。判定まで) | 各検査の **検査承認** | -| 出荷 | ― | **出荷指示**(サンプル商事株式会社・FG-1000 ×5)→ **出荷判定**(可)→ **承認** → **出荷実行**(FG1000-S001 から5台) | +| 出荷 | `13`〜`15`(出荷指示2件・出荷判定・出荷実行。SMP-SH-001 は出荷まで完了、SMP-SH-002 は判定が保留のまま) | SMP-SH-002 の出荷判定を「可」にして承認し、出荷実行してみる | | 追跡 | ― | トレーサビリティで `FG1000-S001` や `R3001-260901` を追跡 |
diff --git a/samples/actual-csv/13_shipping-orders.csv b/samples/actual-csv/13_shipping-orders.csv new file mode 100644 index 0000000..50f5d4b --- /dev/null +++ b/samples/actual-csv/13_shipping-orders.csv @@ -0,0 +1,3 @@ +ShippingNo,Destination,PlannedDate,ProductCode,Quantity +SMP-SH-001,サンプル商事株式会社,2026-10-16,FG-1000,5 +SMP-SH-002,サンプル工業株式会社,2026-10-30,FG-1000,3 diff --git a/samples/actual-csv/14_shipment-judgments.csv b/samples/actual-csv/14_shipment-judgments.csv new file mode 100644 index 0000000..329a535 --- /dev/null +++ b/samples/actual-csv/14_shipment-judgments.csv @@ -0,0 +1,3 @@ +ShippingNo,LotNumber,Result,Approve,Note +SMP-SH-001,FG1000-S001,Approved,true,完成品検査合格(グレードA)を確認 +SMP-SH-002,,Hold,false,納期調整中のため保留(出荷実行はできない) diff --git a/samples/actual-csv/15_shipments.csv b/samples/actual-csv/15_shipments.csv new file mode 100644 index 0000000..fa40f2c --- /dev/null +++ b/samples/actual-csv/15_shipments.csv @@ -0,0 +1,2 @@ +ShippingNo,LotNumber,LocationCode,Quantity +SMP-SH-001,FG1000-S001,WH-P01,5 diff --git a/samples/actual-csv/README.md b/samples/actual-csv/README.md index 4242b56..c649ed0 100644 --- a/samples/actual-csv/README.md +++ b/samples/actual-csv/README.md @@ -17,11 +17,14 @@ | 10 | `10_inspections.csv` | 検査 | 受入・工程内・完成品検査。`InspectionKey` が同じ行が1件の検査。測定値の自動判定と、定性項目の判定 | | 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台出荷して完了にする | ## 取り込み方 画面上部の **ZIPで一括取込** に、このフォルダのCSVをまとめたZIPを渡せば1回で取り込めます。個別に取り込むときは、メニューの **管理 → 実績CSV取込** の種別ごとのパネルから取り込みます(受入は在庫・物流、製造指図は生産管理、 -段取り〜製造条件データと作業時間は現場記録、検査は品質管理の権限が必要。トラブル報告は誰でも取り込める)。種別はファイル名の `_` より後ろです +段取り〜製造条件データと作業時間は現場記録、検査は品質管理、出荷判定は品質保証、出荷指示・出荷実行は在庫・物流の権限が必要。トラブル報告は誰でも取り込める)。種別はファイル名の `_` より後ろです (`05_consumptions.csv` と `07_consumptions.csv` はどちらも `consumptions`)。 APIへ直接送ることもできます。 @@ -49,6 +52,10 @@ GET /api/actuals/csv/receiving/template … ヘッダーだけのCSV 生産実績は**開始日時で直を決める**ので、夜勤の実績は夜勤の時間帯に入る時刻にしてください。 - 実行記録・検査・作業時間・トラブル報告の**記録者は取り込んだユーザー**になります。 CSVで別の作業者の名前を書く列はありません(他人の名義で実績を作れないようにするため)。 +- **出荷指示は「出荷番号」で指します**。出荷判定・出荷実行のCSVから指せるよう、出荷指示CSVの `ShippingNo` は必須です + (`SH` で始まる番号は自動採番用のため指定できません)。出荷番号が同じ行は1件の指示・1回の出荷にまとまります。 +- 出荷実行には、その出荷指示を対象にした**承認済みの出荷判定(可・特採)**が要ります。判定の無い指示・保留の指示を + 出荷実行CSVに書くと、そのファイルは取り込めません。 - `DV-03`(校正期限切れ)を `DeviceCode` に書くと、その検査は取り込めません。 - 部材はバックフラッシュ(`Backflush=true`)でも消費できます。ただし代替部品は消費されない (主材料だけを引く)ので、代替部品を使ったときは投入CSVで理由とともに投入してください。 diff --git a/src/MesApp.Api/Controllers/ShipmentJudgmentsController.cs b/src/MesApp.Api/Controllers/ShipmentJudgmentsController.cs index e7a49c7..767cd68 100644 --- a/src/MesApp.Api/Controllers/ShipmentJudgmentsController.cs +++ b/src/MesApp.Api/Controllers/ShipmentJudgmentsController.cs @@ -1,7 +1,5 @@ -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.Quality; using MesApp.Core.Entities; @@ -21,8 +19,7 @@ namespace MesApp.Api.Controllers; [Authorize] public class ShipmentJudgmentsController( MesAppDbContext db, - NumberingService numbering, - IAuditLogger auditLogger) : ControllerBase + ShipmentJudgmentService judgments) : ControllerBase { /// 出荷判定一覧(H-10-10-01) [HttpGet] @@ -53,35 +50,14 @@ public async Task> Get(int id, Cancellati public async Task> Create( ShipmentJudgmentCreateRequest request, CancellationToken ct) { - if (request.LotId is null && request.ShippingOrderId is null) + var outcome = await judgments.CreateAsync(request, User.FindFirstValue(ClaimTypes.NameIdentifier)!, ct); + if (outcome.Failed) { - return this.BadRequestProblem(ApiText.T("対象ロットIDまたは出荷指示IDを指定してください。")); + return ToProblem(outcome); } - if (request.LotId is int lotId && !await db.Lots.AnyAsync(l => l.Id == lotId, ct)) - { - return this.BadRequestProblem(ApiText.T("存在しないロットIDです。")); - } - if (request.ShippingOrderId is int shippingOrderId - && !await db.ShippingOrders.AnyAsync(s => s.Id == shippingOrderId, ct)) - { - return this.BadRequestProblem(ApiText.T("存在しない出荷指示IDです。")); - } - - var judgment = new ShipmentJudgment - { - JudgmentNo = await numbering.NextJudgmentNoAsync(ct), - LotId = request.LotId, - ShippingOrderId = request.ShippingOrderId, - Result = request.Result, - JudgedByUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)!, - Note = request.Note, - }; - db.ShipmentJudgments.Add(judgment); - await db.SaveChangesAsync(ct); - await auditLogger.LogAsync("Quality", "ShipmentJudge", nameof(ShipmentJudgment), judgment.Id.ToString(), - detail: $"judgmentNo={judgment.JudgmentNo}, result={judgment.Result}", ct: ct); - var saved = await BaseQuery().FirstAsync(j => j.Id == judgment.Id, ct); - return CreatedAtAction(nameof(Get), new { id = judgment.Id }, ToResponse(saved)); + var id = outcome.Value!.Id; + var saved = await BaseQuery().FirstAsync(j => j.Id == id, ct); + return CreatedAtAction(nameof(Get), new { id }, ToResponse(saved)); } /// 判定承認(H-10-10-03) @@ -89,24 +65,22 @@ await auditLogger.LogAsync("Quality", "ShipmentJudge", nameof(ShipmentJudgment), [Authorize(Roles = MesRoleGroups.QaManage)] public async Task> Approve(int id, CancellationToken ct) { - var judgment = await db.ShipmentJudgments.FirstOrDefaultAsync(j => j.Id == id, ct); - if (judgment is null) - { - return NotFound(); - } - if (judgment.ApprovedAt is not null) + var outcome = await judgments.ApproveAsync(id, User.FindFirstValue(ClaimTypes.NameIdentifier), ct); + if (outcome.Failed) { - return this.ConflictProblem(ApiText.T("既に承認済みです。")); + return ToProblem(outcome); } - judgment.ApprovedByUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); - judgment.ApprovedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(ct); - await auditLogger.LogAsync("Quality", "ShipmentJudgeApprove", nameof(ShipmentJudgment), id.ToString(), - detail: $"judgmentNo={judgment.JudgmentNo}", ct: ct); var saved = await BaseQuery().FirstAsync(j => j.Id == id, ct); return ToResponse(saved); } + private ActionResult ToProblem(Outcome outcome) => outcome.Kind switch + { + OutcomeError.NotFound => NotFound(), + OutcomeError.Conflict => this.ConflictProblem(outcome.Error), + _ => this.BadRequestProblem(outcome.Error), + }; + private IQueryable BaseQuery() => db.ShipmentJudgments.AsNoTracking() .Include(j => j.Lot) diff --git a/src/MesApp.Api/Controllers/ShippingOrdersController.cs b/src/MesApp.Api/Controllers/ShippingOrdersController.cs index 1c7fcdf..c006a88 100644 --- a/src/MesApp.Api/Controllers/ShippingOrdersController.cs +++ b/src/MesApp.Api/Controllers/ShippingOrdersController.cs @@ -55,7 +55,7 @@ public async Task> Get(int id, CancellationT public async Task> Create( ShippingOrderCreateRequest request, CancellationToken ct) { - var outcome = await shipping.CreateAsync(request, CurrentUserId, ct); + var outcome = await shipping.CreateAsync(request, shippingNo: null, CurrentUserId, ct); if (outcome.Failed) { return ToProblem(outcome); diff --git a/src/MesApp.Api/Localization/ApiText.en.resx b/src/MesApp.Api/Localization/ApiText.en.resx index 12ac27f..7cca82f 100644 --- a/src/MesApp.Api/Localization/ApiText.en.resx +++ b/src/MesApp.Api/Localization/ApiText.en.resx @@ -1896,4 +1896,91 @@ Product '{1}', process '{2}' on production date {0:yyyy-MM-dd} already has a plan for work center '{3}', which overlaps with work center '{4}'. Plans with and without a work center, or for a parent and its child work center, cannot coexist because the plan-vs-actual view would count them twice. + + The shipment judgment ID does not exist. + + + Shipping number '{0}' cannot be specified because it overlaps the auto-numbering format ({1}...). + + + Shipping number '{0}' already exists. + + + Quantity (instructed quantity) is required. + + + Shipping number '{0}' has duplicate rows for product '{1}'. + + + Either ShippingNo (shipping number) or LotNumber (target lot number) is required. + + + Quantity (shipping quantity) is required. + + + Shipping order '{0}' is not registered (ShippingNo). + + + Shipping orders + + + Shipment judgments + + + Shipments + + + Shipping number + + + Destination + + + Planned shipping date + + + Instructed quantity + + + Shipping lot number + + + Source location code + + + Shipping quantity + + + Rows with the same number are grouped into one shipping order. Later shipment judgment and shipment CSVs refer to the order by this number. Numbers starting with SH are reserved for auto-numbering. + + + The value of the first row in the group is used. + + + yyyy-MM-dd. The value of the first row in the group is used. + + + A registered, active product code. The same product cannot appear on two rows of one order. + + + A registered shipping number (ShippingNo in the shipping order CSV). Shipping requires an approved judgment on the shipping order. + + + A registered lot number. At least one of this and ShippingNo is required. + + + Approved / Hold / SpecialAcceptance + + + true approves the judgment right after registering it. + + + A registered shipping number (ShippingNo in the shipping order CSV). Rows with the same number are shipped together. + + + A registered lot number of a product in the shipping order, in a usable state (normal and not expired). + + + A number greater than 0. Together with the quantity already shipped, it cannot exceed the instructed quantity. + diff --git a/src/MesApp.Api/MesAppHost.cs b/src/MesApp.Api/MesAppHost.cs index 141285a..4743996 100644 --- a/src/MesApp.Api/MesAppHost.cs +++ b/src/MesApp.Api/MesAppHost.cs @@ -85,6 +85,7 @@ 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(); diff --git a/src/MesApp.Api/Services/ActualCsvKinds.cs b/src/MesApp.Api/Services/ActualCsvKinds.cs index 982e89a..e5bf0f5 100644 --- a/src/MesApp.Api/Services/ActualCsvKinds.cs +++ b/src/MesApp.Api/Services/ActualCsvKinds.cs @@ -28,9 +28,13 @@ public static class ActualCsvKinds public const string WorkTimeRecords = "work-time-records"; public const string TroubleReports = "trouble-reports"; public const string EquipmentLogs = "equipment-logs"; + public const string ShippingOrders = "shipping-orders"; + public const string ShipmentJudgments = "shipment-judgments"; + public const string Shipments = "shipments"; private const string OrderNoNote = "登録済みの指図番号(製造指図CSVの OrderNo)"; private const string SequenceNote = "工順の工程順序。指図番号と合わせて作業指示を指す(展開済みであること)"; + private const string ShippingNoNote = "登録済みの出荷番号(出荷指示CSVの ShippingNo)"; private const string DateTimeNote = "yyyy-MM-dd HH:mm(工場の時刻)。+09:00 などのオフセット付きも可"; public static readonly List All = @@ -158,6 +162,30 @@ public static class ActualCsvKinds new("Sequence", "工程順序", false, "指図番号を書いたときは必須"), new("Note", "備考", false, null), ]), AnyRole), + new(new CsvKindInfo(ShippingOrders, "出荷指示", false, + [ + new("ShippingNo", "出荷番号", true, + "同じ番号の行を1件の出荷指示にまとめる。後続の出荷判定・出荷実行CSVから指示を指す。SHで始まる番号は自動採番用のため使えない"), + new("Destination", "出荷先", true, "まとまりの最初の行の値を使う"), + new("PlannedDate", "出荷予定日", false, "yyyy-MM-dd。まとまりの最初の行の値を使う"), + new("ProductCode", "品目コード", true, "登録済みで有効な品目コード。1件の指示に同じ品目を2行書けない"), + new("Quantity", "指示数量", true, "0より大きい数値"), + ]), MesRoleGroups.InventoryManage), + new(new CsvKindInfo(ShipmentJudgments, "出荷判定", false, + [ + new("ShippingNo", "出荷番号", false, ShippingNoNote + "。出荷実行には出荷指示を対象にした承認済みの判定が必要"), + new("LotNumber", "対象ロット番号", false, "登録済みのロット番号。ShippingNo と少なくともどちらかは必須"), + new("Result", "判定", true, "Approved(可)/ Hold(保留)/ SpecialAcceptance(特採)"), + new("Approve", "承認する", false, "true で判定の登録に続けて承認する"), + new("Note", "備考", false, null), + ]), MesRoleGroups.QaManage), + new(new CsvKindInfo(Shipments, "出荷実行", false, + [ + new("ShippingNo", "出荷番号", true, ShippingNoNote + "。同じ番号の行を1回の出荷にまとめる"), + new("LotNumber", "出荷ロット番号", true, "登録済みのロット番号。出荷指示に含まれる品目で、使える状態(正常・期限内)であること"), + new("LocationCode", "出荷元ロケーションコード", true, null), + new("Quantity", "出荷数量", true, "0より大きい数値。出荷済みと合わせて指示数量を超えられない"), + ]), MesRoleGroups.InventoryManage), ]; /// diff --git a/src/MesApp.Api/Services/ActualCsvService.Shipping.cs b/src/MesApp.Api/Services/ActualCsvService.Shipping.cs new file mode 100644 index 0000000..a0ab0d9 --- /dev/null +++ b/src/MesApp.Api/Services/ActualCsvService.Shipping.cs @@ -0,0 +1,220 @@ +using MesApp.Api.Localization; +using MesApp.Core.Contracts.Inventory; +using MesApp.Core.Contracts.Masters; +using MesApp.Core.Contracts.Quality; +using MesApp.Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MesApp.Api.Services; + +// 実績CSVのうち出荷(D-40-20 出荷指示、H-10-10 出荷判定、D-40-30 出荷実行)。 +// 出荷判定は品質保証、指示と実行は物流の権限なので、1つの種別にまとめず3つに分ける(ActualCsvKinds) +public sealed partial class ActualCsvService +{ + /// + /// 出荷指示(D-40-20-01)。出荷番号が同じ行を1件の指示にまとめ、1行=1明細(品目)とする。 + /// 後続の出荷判定・出荷実行のCSVは指示を出荷番号で指すため、番号はここで確定させる + /// + private async Task ImportShippingOrdersAsync( + 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 groups = table.Rows + .GroupBy(row => table.Value(row, "ShippingNo")) + .Select(g => g.ToList()) + .ToList(); + + var created = 0; + foreach (var group in groups) + { + var head = new CsvRowReader(table, group[0], errors); + var shippingNo = head.RequiredText("ShippingNo", 50); + var destination = head.RequiredText("Destination", 200); + var plannedDate = head.DateOrNull("PlannedDate", null); + if (head.Failed) + { + continue; + } + + var lines = new List(); + var failed = false; + foreach (var row in group) + { + 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); + if (quantity is null && !reader.Failed) + { + reader.Fail(ApiText.T("Quantity(指示数量)は必須です。")); + } + // 出荷実行はロットの品目で明細を1つに決めて突き合わせるため、同じ品目の明細を2つ持たせない + if (productId is not null && lines.Any(l => l.ProductId == productId)) + { + reader.Fail(ApiText.T("出荷番号 '{0}' に品目 '{1}' の行が重複しています。", shippingNo, table.Value(row, "ProductCode"))); + } + if (reader.Failed) + { + failed = true; + continue; + } + lines.Add(new ShippingLineRequest(productId!.Value, quantity!.Value)); + } + if (failed) + { + continue; + } + + var outcome = await shipping.CreateAsync( + new ShippingOrderCreateRequest(destination, plannedDate, lines), shippingNo, userId, ct); + if (outcome.Failed) + { + FailRow(head, outcome.Error!); + continue; + } + created++; + } + return created; + } + + /// 出荷判定(H-10-10-02)。1行=1件の判定で、列の指定に応じて承認(H-10-10-03)まで進める + private async Task ImportShipmentJudgmentsAsync( + CsvTable table, List errors, string userId, CancellationToken ct) + { + var created = 0; + foreach (var row in table.Rows) + { + var reader = new CsvRowReader(table, row, errors); + var shippingNo = reader.Text("ShippingNo", null); + var lotNumber = reader.Text("LotNumber", null); + reader.RequiredText("Result"); + var result = reader.Enum("Result", ShipmentJudgmentResult.Hold, CsvEnumLabels.ShipmentJudgmentResults); + var approve = reader.Bool("Approve", false); + var note = reader.Text("Note", null, 1000); + if (shippingNo is null && lotNumber is null && !reader.Failed) + { + reader.Fail(ApiText.T("ShippingNo(出荷番号)と LotNumber(対象ロット番号)のどちらかは必須です。")); + } + // 前のファイル(出荷指示・工程展開)で作られた指示・ロットも指せるよう、行ごとにDBを引く + var shippingOrderId = await ResolveShippingOrderAsync(reader, shippingNo, ct); + int? lotId = null; + if (lotNumber is not null) + { + 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 judgments.CreateAsync( + new ShipmentJudgmentCreateRequest(lotId, shippingOrderId, result, note), userId, ct); + if (outcome.Value is { } judgment && approve) + { + outcome = await judgments.ApproveAsync(judgment.Id, userId, ct); + } + if (outcome.Failed) + { + FailRow(reader, outcome.Error!); + continue; + } + created++; + } + return created; + } + + /// + /// 出荷実行(D-40-30)。出荷番号が同じ行を1回の出荷にまとめ、1行=1ロットの引落しとする。 + /// 出荷判定のゲートとロットの使用可否は単票APIと同じく が判定する + /// + private async Task ImportShipmentsAsync( + 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 groups = table.Rows + .GroupBy(row => table.Value(row, "ShippingNo")) + .Select(g => g.ToList()) + .ToList(); + + var created = 0; + foreach (var group in groups) + { + var head = new CsvRowReader(table, group[0], errors); + var shippingNo = head.RequiredText("ShippingNo"); + var shippingOrderId = await ResolveShippingOrderAsync(head, shippingNo, ct); + if (head.Failed) + { + continue; + } + + var lines = new List(); + var failed = false; + foreach (var row in group) + { + var reader = new CsvRowReader(table, row, errors); + var lotNumber = reader.RequiredText("LotNumber"); + reader.RequiredText("LocationCode"); + var locationId = reader.Reference("LocationCode", null, locationIds, "ロケーション"); + var quantity = reader.NumberOrNull("Quantity", null, 0.000001m); + if (quantity is null && !reader.Failed) + { + reader.Fail(ApiText.T("Quantity(出荷数量)は必須です。")); + } + 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) + { + failed = true; + continue; + } + lines.Add(new ShipLineRequest(lotId!.Value, locationId!.Value, quantity!.Value)); + } + if (failed) + { + continue; + } + + var outcome = await shipping.ShipAsync(shippingOrderId!.Value, new ShipExecuteRequest(lines), userId, ct); + if (outcome.Failed) + { + FailRow(head, outcome.Error!); + continue; + } + created++; + } + return created; + } + + /// 出荷番号→出荷指示ID(番号が空ならnull。見つからなければ行をエラーにする) + private async Task ResolveShippingOrderAsync(CsvRowReader reader, string? shippingNo, CancellationToken ct) + { + if (string.IsNullOrEmpty(shippingNo)) + { + return null; + } + var id = await db.ShippingOrders.Where(s => s.ShippingNo == shippingNo) + .Select(s => (int?)s.Id).FirstOrDefaultAsync(ct); + if (id is null) + { + reader.Fail(ApiText.T("出荷指示 '{0}' は登録されていません(ShippingNo)。", shippingNo)); + } + return id; + } +} diff --git a/src/MesApp.Api/Services/ActualCsvService.cs b/src/MesApp.Api/Services/ActualCsvService.cs index ea3bed0..8c58dec 100644 --- a/src/MesApp.Api/Services/ActualCsvService.cs +++ b/src/MesApp.Api/Services/ActualCsvService.cs @@ -27,13 +27,15 @@ namespace MesApp.Api.Services; /// 前の行を登録しないと判定できない条件があるため。 /// /// -public sealed class ActualCsvService( +public sealed partial class ActualCsvService( MesAppDbContext db, ReceivingService receiving, ManufacturingOrderService orders, WorkOrderExecutionService execution, InspectionService inspections, ShopFloorReportService reports, + ShippingService shipping, + ShipmentJudgmentService judgments, IBusinessDateService businessDate, IAuditLogger auditLogger) { @@ -71,6 +73,9 @@ private async Task ImportOneAsync( ActualCsvKinds.WorkTimeRecords => await ImportWorkTimeRecordsAsync(table, errors, userId!, ct), ActualCsvKinds.TroubleReports => await ImportTroubleReportsAsync(table, errors, userId!, ct), ActualCsvKinds.EquipmentLogs => await ImportEquipmentLogsAsync(table, errors, userId, ct), + ActualCsvKinds.ShippingOrders => await ImportShippingOrdersAsync(table, errors, userId, ct), + ActualCsvKinds.ShipmentJudgments => await ImportShipmentJudgmentsAsync(table, errors, userId!, ct), + ActualCsvKinds.Shipments => await ImportShipmentsAsync(table, errors, userId, ct), _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; if (errors.Count == 0) diff --git a/src/MesApp.Api/Services/MasterCsvKinds.cs b/src/MesApp.Api/Services/MasterCsvKinds.cs index aa85aa6..cf9e31f 100644 --- a/src/MesApp.Api/Services/MasterCsvKinds.cs +++ b/src/MesApp.Api/Services/MasterCsvKinds.cs @@ -307,6 +307,10 @@ public static class CsvEnumLabels ("段取り", EquipmentLogStatus.Setup), ("故障", EquipmentLogStatus.Failure), ("アイドル", EquipmentLogStatus.Idle), ("待機", EquipmentLogStatus.Idle)); + public static readonly IReadOnlyDictionary ShipmentJudgmentResults = Build( + ("可", ShipmentJudgmentResult.Approved), ("保留", ShipmentJudgmentResult.Hold), + ("特採", ShipmentJudgmentResult.SpecialAcceptance), ("特別採用", ShipmentJudgmentResult.SpecialAcceptance)); + public static readonly IReadOnlyDictionary MakeOrBuys = Build( ("内製", MakeOrBuy.InHouse), ("外注", MakeOrBuy.Outsourced)); diff --git a/src/MesApp.Api/Services/ShipmentJudgmentService.cs b/src/MesApp.Api/Services/ShipmentJudgmentService.cs new file mode 100644 index 0000000..d6019d6 --- /dev/null +++ b/src/MesApp.Api/Services/ShipmentJudgmentService.cs @@ -0,0 +1,74 @@ +using MesApp.Api.Localization; +using MesApp.Core.Abstractions; +using MesApp.Core.Contracts.Quality; +using MesApp.Core.Entities; +using MesApp.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace MesApp.Api.Services; + +/// +/// 出荷判定(H-10-10。判定(可/保留/特採)→承認の単段階承認)。 +/// 単票API(ShipmentJudgmentsController)と実績CSV取込(ActualCsvService)の両方から呼ぶ。 +/// 出荷実行のゲートの条件は に置く。 +/// 保存と監査ログまで行う。トランザクションは呼び出し側が張る。 +/// +public sealed class ShipmentJudgmentService( + MesAppDbContext db, + NumberingService numbering, + IAuditLogger auditLogger) +{ + /// 出荷判定(H-10-10-02。対象はロットまたは出荷指示) + public async Task> CreateAsync( + ShipmentJudgmentCreateRequest request, string userId, CancellationToken ct) + { + if (request.LotId is null && request.ShippingOrderId is null) + { + return Outcome.Invalid(ApiText.T("対象ロットIDまたは出荷指示IDを指定してください。")); + } + if (request.LotId is int lotId && !await db.Lots.AnyAsync(l => l.Id == lotId, ct)) + { + return Outcome.Invalid(ApiText.T("存在しないロットIDです。")); + } + if (request.ShippingOrderId is int shippingOrderId + && !await db.ShippingOrders.AnyAsync(s => s.Id == shippingOrderId, ct)) + { + return Outcome.Invalid(ApiText.T("存在しない出荷指示IDです。")); + } + + var judgment = new ShipmentJudgment + { + JudgmentNo = await numbering.NextJudgmentNoAsync(ct), + LotId = request.LotId, + ShippingOrderId = request.ShippingOrderId, + Result = request.Result, + JudgedByUserId = userId, + Note = request.Note, + }; + db.ShipmentJudgments.Add(judgment); + await db.SaveChangesAsync(ct); + await auditLogger.LogAsync("Quality", "ShipmentJudge", nameof(ShipmentJudgment), judgment.Id.ToString(), + detail: $"judgmentNo={judgment.JudgmentNo}, result={judgment.Result}", ct: ct); + return Outcome.Ok(judgment); + } + + /// 判定承認(H-10-10-03) + public async Task> ApproveAsync(int id, string? userId, CancellationToken ct) + { + var judgment = await db.ShipmentJudgments.FirstOrDefaultAsync(j => j.Id == id, ct); + if (judgment is null) + { + return Outcome.NotFound(ApiText.T("存在しない出荷判定IDです。")); + } + if (judgment.ApprovedAt is not null) + { + return Outcome.Conflict(ApiText.T("既に承認済みです。")); + } + judgment.ApprovedByUserId = userId; + judgment.ApprovedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + await auditLogger.LogAsync("Quality", "ShipmentJudgeApprove", nameof(ShipmentJudgment), id.ToString(), + detail: $"judgmentNo={judgment.JudgmentNo}", ct: ct); + return Outcome.Ok(judgment); + } +} diff --git a/src/MesApp.Api/Services/ShippingService.cs b/src/MesApp.Api/Services/ShippingService.cs index 04d9f21..dd6c5f3 100644 --- a/src/MesApp.Api/Services/ShippingService.cs +++ b/src/MesApp.Api/Services/ShippingService.cs @@ -23,9 +23,15 @@ public sealed class ShippingService( IBusinessDateService businessDate, IAuditLogger auditLogger) { - /// 出荷指示の作成(D-40-20-01) + /// 自動採番の出荷番号の接頭辞(手入力の番号と衝突させないため、手入力では使わせない) + public const string AutoShippingNoPrefix = "SH"; + + /// + /// 出荷指示の作成(D-40-20-01)。 + /// shippingNo を渡すとその番号で登録する(CSV取込で後続の出荷判定・出荷実行から指示を指すため。空なら自動採番) + /// public async Task> CreateAsync( - ShippingOrderCreateRequest request, string? userId, CancellationToken ct) + ShippingOrderCreateRequest request, string? shippingNo, string? userId, CancellationToken ct) { if (request.Lines.Count == 0) { @@ -37,9 +43,24 @@ public async Task> CreateAsync( return Outcome.Invalid(ApiText.T("存在しない品目IDが含まれています。")); } + if (string.IsNullOrWhiteSpace(shippingNo)) + { + shippingNo = await numbering.NextShippingNoAsync(ct); + } + else if (shippingNo.StartsWith(AutoShippingNoPrefix, StringComparison.OrdinalIgnoreCase)) + { + // 自動採番の連番は採番テーブルで管理しており、同じ形式の手入力番号があると後で衝突する + return Outcome.Invalid( + ApiText.T("出荷番号 '{0}' は自動採番の形式({1}〜)と重なるため指定できません。", shippingNo, AutoShippingNoPrefix)); + } + else if (await db.ShippingOrders.AnyAsync(s => s.ShippingNo == shippingNo, ct)) + { + return Outcome.Conflict(ApiText.T("出荷番号 '{0}' は既に存在します。", shippingNo)); + } + var order = new ShippingOrder { - ShippingNo = await numbering.NextShippingNoAsync(ct), + ShippingNo = shippingNo, Destination = request.Destination, PlannedDate = request.PlannedDate, CreatedByUserId = userId, diff --git a/tests/MesApp.Api.Tests/InventoryTests.cs b/tests/MesApp.Api.Tests/InventoryTests.cs index ceac1fa..9849865 100644 --- a/tests/MesApp.Api.Tests/InventoryTests.cs +++ b/tests/MesApp.Api.Tests/InventoryTests.cs @@ -234,6 +234,71 @@ await admin.PostAsJsonAsync("/api/inventory/move", Assert.Equal(HttpStatusCode.BadRequest, created.StatusCode); } + [Fact] + public async Task 出荷指示と出荷判定と出荷実行をCSVで取り込め_判定の無い出荷は全件取り消される() + { + using var factory = new ApiFactory(); + using var admin = await TestAuth.CreateAdminClientAsync(factory); + await Phase3TestData.SetupAsync(admin); + Assert.True((await Phase3TestData.ImportActualCsvAsync(admin, "receiving", + "ProductCode,Quantity,LocationCode,LotNumber\nFG-01,100,LOC-P,FG-LOT-1\n")).Succeeded); + const string orderHeader = "ShippingNo,Destination,PlannedDate,ProductCode,Quantity\n"; + const string shipHeader = "ShippingNo,LotNumber,LocationCode,Quantity\n"; + + // 自動採番の形式(SH〜)の番号と、1件の指示に同じ品目を2行書いたものは拒否し、正しい行も登録しない + var invalid = await Phase3TestData.ImportActualCsvAsync(admin, "shipping-orders", + orderHeader + + "CSV-SH-1,出荷先A,2026-10-16,FG-01,60\n" + + "SH20260101-0001,出荷先B,,FG-01,10\n" + + "CSV-SH-3,出荷先C,,FG-01,5\n" + + "CSV-SH-3,出荷先C,,FG-01,5\n"); + Assert.False(invalid.Succeeded); + Assert.Contains(invalid.Errors, e => e.Line == 3 && e.Message.Contains("SH20260101-0001")); + Assert.Contains(invalid.Errors, e => e.Line == 5 && e.Message.Contains("FG-01")); + + // 出荷番号が同じ行は1件の指示にまとまる(ここでは1行ずつ2件) + var orders = await Phase3TestData.ImportActualCsvAsync(admin, "shipping-orders", + orderHeader + "CSV-SH-1,出荷先A,2026-10-16,FG-01,60\nCSV-SH-2,出荷先B,,FG-01,10\n"); + Assert.True(orders.Succeeded); + Assert.Equal(2, orders.Created); + + // 判定の無い指示は出荷できず、在庫も動かない(単票APIと同じゲート) + var blocked = await Phase3TestData.ImportActualCsvAsync(admin, "shipments", shipHeader + "CSV-SH-1,FG-LOT-1,LOC-P,20\n"); + Assert.False(blocked.Succeeded); + Assert.Contains("H-10-10", Assert.Single(blocked.Errors).Message); + Assert.Equal(100m, await StockOfProductAsync(admin, "FG-01")); + + // 出荷判定の取込は単票の判定APIと同じ権限(品質保証)。物流は取り込めない + var kinds = await admin.GetFromJsonAsync>("/api/actuals/csv/kinds"); + Assert.Equal(MesRoleGroups.QaManage, kinds!.Single(k => k.Kind == "shipment-judgments").WriteRoles); + Assert.Equal(MesRoleGroups.InventoryManage, kinds!.Single(k => k.Kind == "shipments").WriteRoles); + const string judgmentCsv = "ShippingNo,LotNumber,Result,Approve,Note\nCSV-SH-1,FG-LOT-1,可,true,\nCSV-SH-2,,Hold,false,保留\n"; + using var logistics = await TestAuth.CreateUserClientAsync( + factory, admin, "logistics1", "Passw0rd123", MesRoles.Logistics); + Assert.Equal(HttpStatusCode.Forbidden, + (await Phase3TestData.PostActualCsvAsync(logistics, "shipment-judgments", judgmentCsv)).StatusCode); + var judged = await Phase3TestData.ImportActualCsvAsync(admin, "shipment-judgments", judgmentCsv); + Assert.True(judged.Succeeded); + Assert.Equal(2, judged.Created); + + // 保留の判定しか無い指示は出荷できない + var held = await Phase3TestData.ImportActualCsvAsync(logistics, "shipments", shipHeader + "CSV-SH-2,FG-LOT-1,LOC-P,10\n"); + Assert.False(held.Succeeded); + + // 出荷番号が同じ行は1回の出荷にまとまり、指示数量に達すると完了になる + var shipped = await Phase3TestData.ImportActualCsvAsync(logistics, "shipments", + shipHeader + "CSV-SH-1,FG-LOT-1,LOC-P,20\nCSV-SH-1,FG-LOT-1,LOC-P,40\n"); + Assert.True(shipped.Succeeded, string.Join(" / ", shipped.Errors.Select(e => e.Message))); + Assert.Equal(1, shipped.Created); + Assert.Equal(40m, await StockOfProductAsync(admin, "FG-01")); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var order = await db.ShippingOrders.Include(s => s.Lines).SingleAsync(s => s.ShippingNo == "CSV-SH-1"); + Assert.Equal(ShippingOrderStatus.Completed, order.Status); + Assert.Equal(new DateOnly(2026, 10, 16), order.PlannedDate); + Assert.Equal(60m, Assert.Single(order.Lines).ShippedQuantity); + } + [Fact] public async Task 出荷指示から出荷実行で在庫が引き落とされ完了になる() { diff --git a/tests/MesApp.Api.Tests/MasterCsvTests.cs b/tests/MesApp.Api.Tests/MasterCsvTests.cs index 7933199..7c44af5 100644 --- a/tests/MesApp.Api.Tests/MasterCsvTests.cs +++ b/tests/MesApp.Api.Tests/MasterCsvTests.cs @@ -1315,7 +1315,12 @@ decimal MachiningRate(DateOnly from) => planActual // 実績は同じ種別を番号違いで複数含む(05_consumptions と 07_consumptions) var actualResult = await PostBundleAsync(client, "actuals", actuals); Assert.True(actualResult.Succeeded, Describe(actualResult)); - Assert.Equal(12, actualResult.Files.Count); + Assert.Equal(15, actualResult.Files.Count); + // 出荷は判定を承認した SMP-SH-001 だけが出荷まで進み、保留の SMP-SH-002 は指示のまま残る + var shipping = (await client.GetFromJsonAsync>( + "/api/shipping-orders"))!.Items; + Assert.Equal(ShippingOrderStatus.Completed, shipping.Single(s => s.ShippingNo == "SMP-SH-001").Status); + Assert.Equal(ShippingOrderStatus.Instructed, shipping.Single(s => s.ShippingNo == "SMP-SH-002").Status); // マスタと実績が混ざったZIPはどちらの一括取込でも受け付けない var mixed = ZipFiles(("01_processes.csv", "Code,Name\nPR-99,検査\n"), ("02_receiving.csv", "ProductCode,Quantity,LocationCode\nRM-3001,1,WH-M01\n"));