勉強のために簡単な生産管理システムを作ってみるテスト。
徐々に拡張する予定。
TL;DR
- 受払とは、受入と払出のこと(別名:入庫と出庫)
- 在庫=受払の結果(前残+受入-払出)
前提
ファンタジー生産管理システムの開発1の続きです。
女騎士さんはオークさんとペアプログラミングしています。
女騎士「ジョブチェンジ直後で初期スキル『C#初心者』だけの私に色々教えてくれて、オークさんには感謝している」
オーク「どういたしまして」
女騎士「しかし、二人でプログラミングするより、ときには一人で書いた方が速いこともあるだろう。自分ではプログラムを書かないのか?」
オーク「あー、実はうまくタイピングできんのだ(指が大きくて)」
女騎士「悪いことを聞いた。……すまない」
オークさんがホワイドボードに記入しました。
製造品ができるイメージおさらい
モデル工場
- 組立産業
- 見込み生産
- 製品「100tハンマー」を製造して出荷している
- 丸太
HOGE
をインクPIYO
によって着色し、ハンマーヘッド部分FOO
を作る - ハンマーヘッド部分
FOO
と木の棒BAR
を組み合わせて、製品100tハンマーBAZ
を作る
図3. モデル工場(着色工程と組立工程を経て製品「100tハンマー」を作る工場)
モデル工場の組立工程に限定して受払を記録するプログラムを考える。
受払とは
- 受払とは、受入と払出のこと(別名:入庫と出庫)
- 製造は受入、投入は払出の代表例
- 通常、製造・投入の記録は製造品/工程/製造指図などと紐づける
- 在庫=受払の結果(前残+受入-払出)
- 安全在庫=製造あるいは出荷を可能とするために、最低限必要とする在庫のこと
- 在庫を保持することにはコスト・リスクが発生するため、なるべく減らすべきとされる
- 一般に、受注生産より見込み生産の方が在庫量が多くなる傾向がある
例えば、上図でFOOは前残(もともと持っていた在庫)がない状態で、受入10、払出6であるので、最終的な残在庫は4となる。
消費期限・賞味期限のある食品をはじめとして、品目の種類によっては在庫としての価値を長期間維持できず、数時間~数日、数ヶ月に有効期限が限定されてしまう場合もある。
パッと思いついた工場内での取引
取引とはモノやお金の交換のこと。
例えば、
・モノ同士を交換する:投入-製造
・モノとお金を交換する:購入や販売
・お金同士を交換する:為替
など。
モノの数量が変化する受払系取引を3桁の数字(文字列)で表現することにする。必要なら追加する。
今回使うのは太字部分だけ。
取引 | 取引種別 | 取引内容 |
---|---|---|
000~099 | - | 未使用 |
100 投入使用 | 払出 | 工程に投入した |
105 棚差(投入差) | 払出 | 投入品の理論残と実残に差があった(±の出庫) |
110 品目振替出庫 | 払出 | 別品名の同価値のものへ振替した |
130 出荷 | 払出 | 最終製品を工場から出荷した |
131 出荷返品 | 払出 | 出荷した製品が工場に戻ってきた(マイナスの出庫) |
190 特別出庫 | 払出 | とりあえず出庫した |
191 特別出庫返品 | 払出 | とりあえず出庫したものを返した(マイナスの出庫) |
200 製造実績 | 受入 | 工程で製造した |
201 製造戻り | 受入 | 製造に失敗した |
205 棚差(製造差) | 受入 | 最終製品の理論残と実残に差があった(±の入庫) |
210 品目振替入庫 | 受入 | 別品名の同価値のものから振替した |
230 購買受入 | 受入 | 購入した |
231 購入返品 | 受入 | 購入したものを返した(マイナスの入庫) |
290 特別入庫 | 受入 | とりあえず入庫した |
291 特別返品 | 受入 | とりあえず入庫したものを返した(マイナスの入庫) |
今回の仕様
- コンソールアプリケーション
-
コマンド パラメータ
の書式が標準入力で連続入力される - 実績入力機能
- コマンド
j パラメータ
またはjisseki パラメータ
で実績を入力する - パラメータ部分の書式は
取引,品目,取引数量,(取引日付)
とする - 入力のたび、入力した内容が分かるように標準出力で表示する
- コマンド
-
在庫表示機能
- コマンド
c
またはcheck
が入力されたら、各品目の現在の在庫を表示する
- コマンド
- 終了機能
- コマンド
q
またはquit
、exit
が入力されたら終了する
- コマンド
- 例外処理は考えなくてもよい
例
fpms> j 200,FOO,10,
FOOを10製造
fpms> j 100,FOO,6,
FOOを6投入
fpms> j 230,BAR,24,
BARを24購入
fpms> j 100,BAR,6,
BARを6投入
fpms> j 200,BAZ,6,
BAZを6製造
fpms> j 130,BAZ,5,
BAZを5出荷
fpms> c
品目FOOの在庫:4
品目BARの在庫:18
品目BAZの在庫:1
アンデッドさんがプログラムを修正しました。
女騎士「今回の仕様を意識して、前回作ったプログラムをある程度修正してくれてあるのか」
オーク「アンデッドさんがやってくれた。あとは標準出力部分と在庫表示機能を実装するだけになっている」
女騎士「ありがたいな」
オーク「これからは製造以外の数値入力もあるはずだから、各種取引を入力できるようにアンデッドさんにお願いしておいた」
女騎士「100番台が払出系、200番台が受入系の取引と定義したのは分かったが、0番台の取引を使わなかった理由は何かあるのか?」
オーク「昔、CSVデータが0落ちするという地獄に居てな……(遠い目)」
女騎士「? とりあえず、使わない方が無難そうだな」
アンデッドさんのプログラム修正内容(オークさんの修正依頼)
- SplitOnce関数を追加
- 複数のデータ入力に対応。「コマンド データ文字列」の形式とする。
- EqualAnyKeyWord関数を追加
- 複数のコマンドを認識できるように修正。
- 半角スペース区切りのパースが面倒な未来が見えたので、データ文字列部分は半角カンマ(,)区切り。
- 簡易的に取得データを格納するため、RecordJクラスを追加
- 禁則文字対応とか、整合性チェックはまだ。
コマンド説明
j | jisseki データ文字列
データ文字列を読み込む。データ文字列の書式「取引,品目,取引数量,(取引日付)」
取引日付がない場合、代わりに取込日時をセットする。
q | quit | exit
プログラムを終了する
アンデッドさんの成果物
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace FantasyProductionManagementSystem
{
class RecordJ
{
public string Trn { get;set; } // 取引
public string Itm { get;set; } // 品目
public decimal Trnqty { get;set; } // 取引数量
public DateTime Trndte { get;set; } // 取引日付
public RecordJ()
{
}
public RecordJ(string csvLine)
{
try
{
string[] dat = csvLine.Split(',');
Trn = dat[0].Trim();
Itm = dat[1].Trim();
Trnqty = Convert.ToDecimal(dat[2].Trim());
if (string.IsNullOrEmpty(dat[3]))
{
Trndte = DateTime.Now;
}
else
{
Trndte = Convert.ToDateTime(dat[3].Trim());
}
}
catch
{
throw new Exception("フォーマットエラー");
}
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
namespace FantasyProductionManagementSystem
{
class Program
{
const string DEFAULT_PROMPT_MESSAGE = "fpms> ";
#region ReadLine
static string ReadLine(string dspPrpMsg)
{
Console.Write(dspPrpMsg);
return Console.ReadLine();
}
#endregion ReadLine
#region SplitOnce
/// <summary>
/// 1回だけ分割する
/// </summary>
/// <param name="str">分割対象の文字列</param>
/// <param name="c">デリミタ文字</param>
/// <returns>分割後の文字列配列ret(分割できない場合、ret[0]のみ値あり)</returns>
static string[] SplitOnce(string str, char c)
{
string[] ret = new string[2];
if (!string.IsNullOrEmpty(str))
{
int idx = str.IndexOf(c);
// idx < 0 ⇒ 存在しない
// idx == 0 ⇒ 最初に存在
// idx == str.Length - 1 ⇒ 最後に存在
if (0 < idx && idx < str.Length - 1)
{
ret[0] = str.Substring(0, idx);
ret[1] = str.Substring(idx + 1);
}
else
{
ret[0] = str;
}
}
return ret;
}
#endregion SplitOnce
#region EqualAnyKeyWord
/// <summary>
/// いずれかのキーワードと一致するか?
/// </summary>
/// <param name="chkStr">チェック対象文字列</param>
/// <param name="keyWrds">キーワード(|区切り)</param>
/// <returns>比較結果(true ⇒ 一致する、false ⇒ 一致しない)</returns>
static bool EqualAnyKeyWord(string chkStr, string keyWrds)
{
List<string> keys = keyWrds.Replace(" ", "").ToLower().Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries).ToList<string>();
return keys.Contains(chkStr.ToLower());
}
#endregion EqualAnyKeyWord
#region Main
static int Main(string[] args)
{
RecordJ recJ;
bool flgEnd = false; // true⇒ループ終了、false⇒ループ続行
while (!flgEnd)
{
// 標準入力(半角スペースで区切ってある)
string line = ReadLine(DEFAULT_PROMPT_MESSAGE).Trim();
string[] prms = SplitOnce(line, ' ');
// 「j 製造数量」を受け取って標準出力
if (EqualAnyKeyWord(prms[0], "j|jisseki"))
{
try
{
recJ = new RecordJ(prms[1]);
Console.WriteLine("取引,品目,取引数量,取引日付:{0},{1},{2},{3}"
, recJ.Trn
, recJ.Itm
, recJ.Trnqty.ToString()
, recJ.Trndte.ToString("yyyy/MM/dd HH:mm:ss")
);
}
catch
{
Console.WriteLine("入力エラーです。");
}
}
else if (EqualAnyKeyWord(prms[0], "q|quit|exit"))
{
flgEnd = true; // ループ終了
}
else
{
if (!string.IsNullOrEmpty(prms[0]))
{
Console.WriteLine("コマンドが無効です。");
}
}
}
return 0;
}
#endregion Main
}
}
女騎士さんが動くコードを書きました。
女騎士さんの成果物(Main関数だけ抜粋)
#region Main
static int Main(string[] args)
{
RecordJ recJ;
#region あとで場所は移動する
#region 取引(暫定で使うものだけを定義)
DataTable dtM_TRN = new DataTable("M_TRN");
dtM_TRN.Columns.Add("CTRN", Type.GetType("System.String")); // 取引
dtM_TRN.Columns.Add("CTRNNME", Type.GetType("System.String")); // 取引名称
dtM_TRN.Columns.Add("CTRNNME2", Type.GetType("System.String")); // 取引略称(今回のように簡易的に表示するときの略称として使う場合があるとのこと)
dtM_TRN.Columns.Add("CTRNTYP", Type.GetType("System.Int32")); // 取引種別(受入 or 払出。1か-1で定義)
dtM_TRN.PrimaryKey = new DataColumn[] { dtM_TRN.Columns["CTRN"] };
DataRow drM_TRN;
drM_TRN = dtM_TRN.NewRow();
drM_TRN["CTRN"] = "100";
drM_TRN["CTRNNME"] = "投入使用";
drM_TRN["CTRNNME2"] = "投入";
drM_TRN["CTRNTYP"] = -1; // 払出
dtM_TRN.Rows.Add(drM_TRN);
drM_TRN = dtM_TRN.NewRow();
drM_TRN["CTRN"] = "130";
drM_TRN["CTRNNME"] = "出荷";
drM_TRN["CTRNNME2"] = "出荷";
drM_TRN["CTRNTYP"] = -1; // 払出
dtM_TRN.Rows.Add(drM_TRN);
drM_TRN = dtM_TRN.NewRow();
drM_TRN["CTRN"] = "200";
drM_TRN["CTRNNME"] = "製造実績";
drM_TRN["CTRNNME2"] = "製造";
drM_TRN["CTRNTYP"] = 1; // 受入
dtM_TRN.Rows.Add(drM_TRN);
drM_TRN = dtM_TRN.NewRow();
drM_TRN["CTRN"] = "230";
drM_TRN["CTRNNME"] = "購買受入";
drM_TRN["CTRNNME2"] = "購入";
drM_TRN["CTRNTYP"] = 1; // 受入
dtM_TRN.Rows.Add(drM_TRN);
#endregion 取引(暫定で使うものだけを定義)
// 標準入力した取引を記録するデータテーブル
DataTable dtT_TRN = new DataTable("T_TRN");
dtT_TRN.Columns.Add("CSEQ", Type.GetType("System.Int32")); // 入力した順にカウンター
dtT_TRN.Columns.Add("CTRN", Type.GetType("System.String"));
dtT_TRN.Columns.Add("CITM", Type.GetType("System.String"));
dtT_TRN.Columns.Add("CTRNQTY", Type.GetType("System.Decimal"));
dtT_TRN.Columns.Add("CTRNDTE", Type.GetType("System.DateTime"));
#endregion あとで場所は移動する
int cseq = 0; // T_TRN記録時のカウンタ
bool flgEnd = false; // true⇒ループ終了、false⇒ループ続行
while (!flgEnd)
{
// 標準入力(半角スペースで区切ってある)
string line = ReadLine(DEFAULT_PROMPT_MESSAGE).Trim();
string[] prms = SplitOnce(line, ' ');
// 「j 製造数量」を受け取って標準出力
if (EqualAnyKeyWord(prms[0], "j|jisseki"))
{
try
{
recJ = new RecordJ(prms[1]);
//Console.WriteLine("取引,品目,取引数量,取引日付:{0},{1},{2},{3}"
// , recJ.Trn
// , recJ.Itm
// , recJ.Trnqty.ToString()
// , recJ.Trndte.ToString("yyyy/MM/dd HH:mm:ss")
// );
DataRow drT_TRN = dtT_TRN.NewRow();
drT_TRN["CSEQ"] = ++cseq;
drT_TRN["CTRN"] = recJ.Trn;
drT_TRN["CITM"] = recJ.Itm;
drT_TRN["CTRNQTY"] = recJ.Trnqty;
drT_TRN["CTRNDTE"] = recJ.Trndte;
dtT_TRN.Rows.Add(drT_TRN);
// 2つのデータテーブルを内部結合する
var result = dtT_TRN.AsEnumerable()
.Join(dtM_TRN.AsEnumerable(), drT => drT["CTRN"], drM => drM["CTRN"]
, (drT, drM) => new
{
Seq = drT["CSEQ"],
Trn = drT["CTRN"],
Trnnme2 = drM["CTRNNME2"],
Trntyp = drM["CTRNTYP"],
Itm = drT["CITM"],
Trnqty = drT["CTRNQTY"],
Trndte = drT["CTRNDTE"]
}
).Where(r => Convert.ToInt32(r.Seq) == cseq);
// 1行だけ取得できる
foreach (var inpDat in result)
{
Console.WriteLine("{0}を{1}{2}", inpDat.Itm, inpDat.Trnqty.ToString(), inpDat.Trnnme2);
}
}
catch
{
Console.WriteLine("入力エラーです。");
}
}
else if (EqualAnyKeyWord(prms[0], "c|check"))
{
if (dtT_TRN.Rows.Count > 0)
{
#region 暫定対応
// 2つのデータテーブルを内部結合して集約してsumするクエリ式とかLINQとか分からなかったのでとりあえず強引に足してみる
var result = dtT_TRN.AsEnumerable()
.Join(dtM_TRN.AsEnumerable(), drT => drT["CTRN"], drM => drM["CTRN"]
, (drT, drM) => new
{
Seq = drT["CSEQ"],
Trn = drT["CTRN"],
Trnnme2 = drM["CTRNNME2"],
Trntyp = drM["CTRNTYP"],
Itm = drT["CITM"],
Trnqty = drT["CTRNQTY"],
Trndte = drT["CTRNDTE"]
}
);
// 今後の案1:DataTableかDataViewで一撃で集約する
// 今後の案2:classかstructでもう少し分かりやすくまとめる
// データベースなるものがあるらしいので、今後はそちらでデータを管理するかも
HashSet<string> hs = new HashSet<string>();
List<string> lstItm = new List<string>();
List<decimal> lstQty = new List<decimal>();
foreach (var j in result)
{
string itm = j.Itm.ToString();
decimal q = Convert.ToDecimal(j.Trnqty) * Convert.ToInt32(j.Trntyp);
if (hs.Add(itm))
{
lstItm.Add(itm);
lstQty.Add(q);
}
else
{
lstQty[lstItm.IndexOf(itm)] += q;
}
}
for (int i = 0; i < lstItm.Count; i++)
{
Console.WriteLine("品目{0}の在庫:{1}", lstItm[i], lstQty[i].ToString());
}
#endregion 暫定対応
}
else
{
Console.WriteLine("在庫情報なし");
}
}
else if (EqualAnyKeyWord(prms[0], "q|quit|exit"))
{
flgEnd = true; // ループ終了
}
else
{
if (!string.IsNullOrEmpty(prms[0]))
{
Console.WriteLine("コマンドが無効です。");
}
}
}
return 0;
}
#endregion Main
参考
画像は化け猫缶素材屋さんからフリー素材を利用させていただきました。