Java / LESSON 18 / JV18

Java:総合演習:在庫管理システム

1学習の目的

2基礎解説

役割使う技術(学んだ章)
① データ商品を表現するクラス・カプセル化(JV08・09)
② 種類違いを吸収する継承・インターフェース(JV10・11)
③ 操作集計・検索するList/Map・Stream(JV12・16)
④ 結果失敗を表現するResult型・例外(JV13・15)
✅ 設計を組み立てる手順
  1. まず扱うデータをクラスにする。商品には何の情報が必要か——名前・価格・在庫。フィールドは private にし、検証つきの setter を用意する(JV09)。
  2. 種類ごとの違いを見つける。書籍だけ送料が違うなら、継承で shippingFee() を上書きする(JV10)。
  3. 「できること」はインターフェースで揃える。配送できる・割引できるといった性質は implements で表現する(JV11)。
  4. 集計は Stream で書く。合計・グループ化・並べ替えは、ループより Stream のほうが意図が明確になる(JV16)。
  5. 失敗の表現を決める。想定内の失敗(在庫不足)は Result 型、異常事態(不正な値)は例外——使い分けが設計の質を決める

この章の題材:商品マスタ・在庫管理・注文処理を備えた在庫管理システム。実務で最も多い業務システムの型です。

// ① インターフェース(できること)
interface Shippable {
    int shippingFee();
}

// ② 親クラス(共通のデータと処理)
static class Product implements Shippable {
    private final String name;       // 変更不可
    private final String category;
    private int price;
    private int stock;

    Product(String name, String category, int price, int stock) {
        this.name = name;
        this.category = category;
        setPrice(price);             // 検証を通す
        setStock(stock);
    }

    public void setStock(int stock) {
        if (stock < 0) {
            throw new IllegalArgumentException("在庫は0以上");
        }
        this.stock = stock;
    }

    public int shippingFee()  { return 600; }
    public int stockValue()   { return price * stock; }

    public String label() {
        if (stock == 0)  return "品切れ";
        if (stock < 10)  return "残りわずか";
        return "在庫あり";
    }

    public boolean reduce(int n) {
        if (n > stock) return false;
        stock -= n;
        return true;
    }
}

// ③ 子クラス(違いだけ書く)
static class Book extends Product {
    Book(String name, int price, int stock) {
        super(name, "書籍", price, stock);
    }

    @Override
    public int shippingFee() { return 0; }
}

// ④ Result型(失敗を値として返す)
static Result<Integer> order(Product p, int qty) {
    if (qty <= 0) return Result.fail("数量が不正です");
    if (!p.reduce(qty)) {
        return Result.fail("在庫不足です(残り" + p.getStock() + "個)");
    }
    return Result.ok(p.getPrice() * qty + p.shippingFee());
}

// ⑤ Stream で集計
int total = items.stream().mapToInt(Product::stockValue).sum();

Map<String, Integer> byCategory = items.stream()
    .collect(Collectors.groupingBy(
        Product::getCategory, TreeMap::new,
        Collectors.summingInt(Product::stockValue)));

3基本ドリル(10問)

JV18-D01 カプセル化した Product クラスを作れ(name は final、stock は検証つき setter)。在庫を出力すること。 コーディング★☆☆無料
期待される結果

出力 == 5

ヒント

private final String name; と setStock で負の値を拒否する。

模範解答
public class Main {

    static class Product {
        private final String name;
        private int stock;

        Product(String name, int stock) {
            this.name = name;
            setStock(stock);
        }

        public void setStock(int stock) {
            if (stock < 0) {
                throw new IllegalArgumentException("在庫は0以上");
            }
            this.stock = stock;
        }

        public int getStock() {
            return stock;
        }
    }

    public static void main(String[] args) {
        System.out.println(new Product("ノートPC", 5).getStock());
    }
}
解説

コンストラクタから setter を呼ぶことで、生成時にも検証が働く(JV09)。これが不正なデータを防ぐ第一歩。

JV18-D02 「在庫不足」を表現する方法として、この教材で推奨しているのはどれか。
A. 例外を投げる B. Result型で返す C. -1を返す D. null を返す
選択★☆☆無料
期待される結果

解答 == B

ヒント

在庫不足は異常事態か、想定内の結果か。

模範解答
B
解説

在庫不足は日常的に起こることなので、例外ではなく戻り値で表現する。異常事態(不正な値)だけを例外にする。

JV18-D03 Product を継承した Book を作れ(送料0円)。親と子の送料を2行出力すること。 コーディング★☆☆無料
期待される結果

出力2行。600 / 0

ヒント

@Override で shippingFee を上書きする(JV10)。

模範解答
public class Main {

    static class Product {
        public int shippingFee() { return 600; }
    }

    static class Book extends Product {
        @Override
        public int shippingFee() { return 0; }
    }

    public static void main(String[] args) {
        System.out.println(new Product().shippingFee());
        System.out.println(new Book().shippingFee());
    }
}
解説

違う部分だけを子クラスに書く。名前や価格の処理は親に1回書けば済む。

JV18-D04 在庫状態を判定するメソッドを完成させよ。 穴埋め★☆☆無料
コード
public class Main {

    static String label(int stock) {
        if (stock == 0) return "品切れ";
        if (stock < 10) ____ "残りわずか";
        return "在庫あり";
    }

    public static void main(String[] args) {
        System.out.println(label(5));
    }
}
期待される結果

出力 == 残りわずか

ヒント

値を返すキーワード(6文字)。

模範解答
return
解説

早期リターンで条件を段階的に絞る(JV07)。else を書かずに済み、インデントが深くならない。

JV18-D05 カテゴリ別の合計を求めるとき、最も簡潔なのはどれか。
A. 二重ループ B. Stream の groupingBy C. 配列を複数用意する D. if文で分岐
選択★☆☆無料
期待される結果

解答 == B

ヒント

JV16で学んだ集計方法。

模範解答
B
解説

groupingBy なら1文で書ける。カテゴリが何種類あるか事前に分からなくても対応できる。

JV18-D06 Stream で在庫総額を求めよ(3件の Product から price × stock の合計)。 コーディング★★☆広告解放
期待される結果

出力 == 796800

ヒント

mapToInt で計算し sum() する(JV16)。

模範解答
import java.util.*;

public class Main {

    static class Product {
        int price;
        int stock;

        Product(int price, int stock) {
            this.price = price;
            this.stock = stock;
        }

        int stockValue() { return price * stock; }
    }

    public static void main(String[] args) {
        List<Product> items = List.of(
                new Product(128000, 5),
                new Product(3200, 42),
                new Product(2800, 8));

        System.out.println(items.stream().mapToInt(Product::stockValue).sum());
    }
}
解説

メソッド参照で簡潔に書けるProduct::stockValuep -> p.stockValue() と同じ。

JV18-D07 在庫を減らすメソッドを完成させよ(在庫不足なら false)。 穴埋め★★☆広告解放
コード
public class Main {

    static class Product {
        private int stock = 5;

        public boolean reduce(int n) {
            if (n > stock) return ____;
            stock -= n;
            return true;
        }
    }

    public static void main(String[] args) {
        System.out.println(new Product().reduce(100));
    }
}
期待される結果

出力 == false

ヒント

失敗を表す真偽値(5文字)。

模範解答
false
解説

失敗しても在庫は変わらない。先に判定してから減らすのが、状態を壊さない書き方(JV09)。

JV18-D08 商品名を final にする理由はどれか。
A. 速くなる B. 「生成後は変わらない」という業務ルールを表現する C. 必須だから D. メモリ節約
選択★★☆広告解放
期待される結果

解答 == B

ヒント

設計の意図をコードで示す。

模範解答
B
解説

final は意図の表明。「この値は変わらない」と宣言すれば、誤って変更するコードはコンパイルで止められる。

JV18-D09 Result型で注文を処理せよ(数量0なら失敗)。成功と失敗の2通りを2行出力すること。 コーディング★★☆広告解放
期待される結果

出力2行。256000 / 数量が不正です

ヒント

Result.ok と Result.fail を使い分ける(JV15)。

模範解答
public class Main {

    static class Result<T> {
        private T value;
        private String error;

        private Result(T value, String error) {
            this.value = value;
            this.error = error;
        }

        static <T> Result<T> ok(T value) { return new Result<>(value, null); }
        static <T> Result<T> fail(String e) { return new Result<>(null, e); }

        boolean isOk() { return error == null; }
        T getValue() { return value; }
        String getError() { return error; }
    }

    static Result<Integer> order(int price, int qty) {
        if (qty <= 0) {
            return Result.fail("数量が不正です");
        }
        return Result.ok(price * qty);
    }

    public static void main(String[] args) {
        for (int qty : new int[]{2, 0}) {
            Result<Integer> r = order(128000, qty);
            System.out.println(r.isOk() ? r.getValue() : r.getError());
        }
    }
}
解説

戻り値の型に「失敗しうる」ことが現れている。呼び出し側は isOk() を確認せざるをえない。

JV18-D10 カテゴリ別の合計を Stream で集計せよ(TreeMap でキー順)。 コーディング★★☆広告解放
期待される結果

出力 == {PC=128000, 周辺機器=48200}

ヒント

groupingBy に TreeMap::new と summingInt を渡す(JV16)。

模範解答
import java.util.*;
import java.util.stream.*;

public class Main {

    static class Product {
        String category;
        int price;

        Product(String category, int price) {
            this.category = category;
            this.price = price;
        }

        String getCategory() { return category; }
        int getPrice() { return price; }
    }

    public static void main(String[] args) {
        List<Product> items = List.of(
                new Product("PC", 128000),
                new Product("周辺機器", 3200),
                new Product("周辺機器", 45000));

        Map<String, Integer> byCategory = items.stream()
                .collect(Collectors.groupingBy(
                        Product::getCategory,
                        TreeMap::new,
                        Collectors.summingInt(Product::getPrice)));

        System.out.println(byCategory);
    }
}
解説

JV12でMapを手で組み立てた集計が1文になる。カテゴリの種類を意識する必要がない。

4実践シナリオ(5問)

JV18-S01 【商品の一覧表示】Product に toString() を定義し、3件を「ノートPC:128,000円 × 5個 = 640,000円(残りわずか)」の形式で出力せよ。 コーディング★★☆広告解放
期待される結果

出力3行

ヒント

toString の中で stockValue() と label() を呼ぶ。

模範解答
import java.util.*;

public class Main {

    static class Product {
        private String name;
        private int price;
        private int stock;

        Product(String name, int price, int stock) {
            this.name = name;
            this.price = price;
            this.stock = stock;
        }

        int stockValue() { return price * stock; }

        String label() {
            if (stock == 0) return "品切れ";
            if (stock < 10) return "残りわずか";
            return "在庫あり";
        }

        @Override
        public String toString() {
            return String.format("%s:%,d円 × %d個 = %,d円(%s)",
                    name, price, stock, stockValue(), label());
        }
    }

    public static void main(String[] args) {
        List<Product> items = List.of(
                new Product("ノートPC", 128000, 5),
                new Product("マウス", 3200, 42),
                new Product("モニター", 45000, 0));

        items.forEach(System.out::println);
    }
}
解説

表示の責任をクラス自身が持つitems.forEach(System.out::println) だけで一覧が出力できる。

JV18-S02 【送料の使い分け】Product(600円)と Book(0円)を混ぜたリストから、送料の合計を Stream で求めよ。 コーディング★★☆広告解放
期待される結果

出力 == 1200

ヒント

ポリモーフィズムで、それぞれの shippingFee が呼ばれる(JV10)。

模範解答
import java.util.*;

public class Main {

    static class Product {
        public int shippingFee() { return 600; }
    }

    static class Book extends Product {
        @Override
        public int shippingFee() { return 0; }
    }

    public static void main(String[] args) {
        List<Product> items = List.of(
                new Product(), new Book(), new Product());

        System.out.println(items.stream()
                .mapToInt(Product::shippingFee).sum());
    }
}
解説

if文で種類を判定していない。ポリモーフィズムのおかげで、リストに何が入っていても正しく計算される。

JV18-S03 【在庫の増減】商品の在庫5に対し、①2個出荷 ②100個出荷(失敗)を行い、各操作の結果と残在庫を2行出力せよ。 コーディング★★☆広告解放
期待される結果

出力2行。true 3 / false 3

ヒント

reduce メソッドの戻り値と getStock を組み合わせる。

模範解答
public class Main {

    static class Product {
        private int stock;

        Product(int stock) { this.stock = stock; }

        public int getStock() { return stock; }

        public boolean reduce(int n) {
            if (n > stock) return false;
            stock -= n;
            return true;
        }
    }

    public static void main(String[] args) {
        Product p = new Product(5);

        System.out.println(p.reduce(2) + " " + p.getStock());
        System.out.println(p.reduce(100) + " " + p.getStock());
    }
}
解説

失敗した操作では状態が変わらない。この性質があるから、呼び出し側は安心して再試行できる。

JV18-S04 【注文処理】Result型で注文を処理せよ(数量不正・在庫不足・成功の3通り)。送料も合計に含めること(単価128000・在庫5・送料600)。 コーディング★★★広告解放
期待される結果

出力3行

ヒント

成功時は price * qty + shippingFee を返す。

模範解答
public class Main {

    static class Result<T> {
        private T value;
        private String error;

        private Result(T v, String e) { value = v; error = e; }
        static <T> Result<T> ok(T v) { return new Result<>(v, null); }
        static <T> Result<T> fail(String e) { return new Result<>(null, e); }

        boolean isOk() { return error == null; }
        T getValue() { return value; }
        String getError() { return error; }
    }

    static class Product {
        int price = 128000;
        int stock = 5;

        int shippingFee() { return 600; }

        boolean reduce(int n) {
            if (n > stock) return false;
            stock -= n;
            return true;
        }
    }

    static Result<Integer> order(Product p, int qty) {
        if (qty <= 0) return Result.fail("数量が不正です");
        if (!p.reduce(qty)) return Result.fail("在庫不足です");
        return Result.ok(p.price * qty + p.shippingFee());
    }

    public static void main(String[] args) {
        Product p = new Product();

        for (int qty : new int[]{0, 100, 2}) {
            Result<Integer> r = order(p, qty);
            System.out.println(r.isOk()
                    ? "成功:" + String.format("%,d", r.getValue()) + "円"
                    : "失敗:" + r.getError());
        }
    }
}
解説

3つの結果が同じ型で返る。呼び出し側は isOk() を見るだけでよく、例外処理を書き分ける必要がない。

JV18-S05 【日付つきレポート】在庫総額を求め、基準日つきで「2026/09/08 時点:796,800円」の形式で出力せよ。 コーディング★★★広告解放
期待される結果

出力 == 2026/09/08 時点:796,800円

ヒント

LocalDate と DateTimeFormatter を使う(JV17)。

模範解答
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;

public class Main {

    static class Product {
        int price;
        int stock;

        Product(int price, int stock) {
            this.price = price;
            this.stock = stock;
        }

        int stockValue() { return price * stock; }
    }

    public static void main(String[] args) {
        List<Product> items = List.of(
                new Product(128000, 5),
                new Product(3200, 42),
                new Product(2800, 8));

        int total = items.stream().mapToInt(Product::stockValue).sum();

        LocalDate date = LocalDate.of(2026, 9, 8);
        DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy/MM/dd");

        System.out.println(date.format(f) + " 時点:"
                + String.format("%,d", total) + "円");
    }
}
解説

レポートには必ず基準日を入れる。「いつ時点の数字か」が分からない報告書は、後から見返したとき役に立たない。

5仕上げ課題

JV18-FINAL 【卒業課題:在庫管理システム】JV01〜JV17のすべてを統合し、実務水準のシステムを作れ。

■ インターフェース Shippable
int shippingFee()

■ クラス Product implements Shippable
private final String name, categoryprivate int price, stock
・コンストラクタから検証つき setter を呼ぶ(負の値は IllegalArgumentException
shippingFee() → 600
stockValue() → price × stock
label() → 0なら「品切れ」、10未満「残りわずか」、以上「在庫あり」
boolean reduce(int n) → 在庫不足なら false(変更しない)
toString() → 「ノートPC:128,000円 × 5個 = 640,000円(残りわずか)」

■ クラス Book extends Product:カテゴリ「書籍」固定、送料0円

■ クラス Result<T>ok / fail / isOk / getValue / getError

■ メソッド order(Product p, int qty)(Result<Integer> を返す)
・数量0以下 → 「数量が不正です」
・在庫不足 → 「在庫不足です(残りN個)」
・成功 → 価格 × 数量 + 送料

■ main の処理
① 商品4件を一覧表示
② 在庫総額(Stream)
③ カテゴリ別の在庫金額(Stream・TreeMap)
④ 注文3件を処理(成功 / 数量不正 / 在庫不足)
⑤ 不正な商品の生成を試みて例外を捕まえる

データ:ノートPC/PC/128000/5、マウス/周辺機器/3200/42、Java入門(Book)/2800/8、モニター/周辺機器/45000/0

期待される出力(11行)
ノートPC:128,000円 × 5個 = 640,000円(残りわずか)
マウス:3,200円 × 42個 = 134,400円(在庫あり)
Java入門:2,800円 × 8個 = 22,400円(残りわずか)
モニター:45,000円 × 0個 = 0円(品切れ)
---
在庫総額:796,800円
カテゴリ別:{PC=640000, 周辺機器=134400, 書籍=22400}
---
成功:256,600円
失敗:数量が不正です
失敗:在庫不足です(残り0個)
総合★★★広告解放
期待される結果

出力11行が完全一致

ヒント

注文はノートPC×2(成功・送料600込み)、マウス×0(数量不正)、モニター×1(在庫不足)。カテゴリ別は groupingBy に TreeMap::new と summingInt(Product::stockValue) を渡す。最後の例外は catch するが出力はしない(11行に含まれないため、捕まえるだけでよい)。

模範解答
import java.util.*;
import java.util.stream.*;

public class Main {

    interface Shippable {
        int shippingFee();
    }

    static class Product implements Shippable {
        private final String name;
        private final String category;
        private int price;
        private int stock;

        Product(String name, String category, int price, int stock) {
            this.name = name;
            this.category = category;
            setPrice(price);
            setStock(stock);
        }

        public String getName() { return name; }
        public String getCategory() { return category; }
        public int getPrice() { return price; }
        public int getStock() { return stock; }

        public void setPrice(int price) {
            if (price < 0) {
                throw new IllegalArgumentException("価格は0以上にしてください");
            }
            this.price = price;
        }

        public void setStock(int stock) {
            if (stock < 0) {
                throw new IllegalArgumentException("在庫は0以上にしてください");
            }
            this.stock = stock;
        }

        public int shippingFee() {
            return 600;
        }

        public int stockValue() {
            return price * stock;
        }

        public String label() {
            if (stock == 0) return "品切れ";
            if (stock < 10) return "残りわずか";
            return "在庫あり";
        }

        public boolean reduce(int n) {
            if (n > stock) return false;
            stock -= n;
            return true;
        }

        @Override
        public String toString() {
            return String.format("%s:%,d円 × %d個 = %,d円(%s)",
                    name, price, stock, stockValue(), label());
        }
    }

    static class Book extends Product {
        Book(String name, int price, int stock) {
            super(name, "書籍", price, stock);
        }

        @Override
        public int shippingFee() {
            return 0;
        }
    }

    static class Result<T> {
        private T value;
        private String error;

        private Result(T value, String error) {
            this.value = value;
            this.error = error;
        }

        static <T> Result<T> ok(T value) { return new Result<>(value, null); }
        static <T> Result<T> fail(String e) { return new Result<>(null, e); }

        boolean isOk() { return error == null; }
        T getValue() { return value; }
        String getError() { return error; }
    }

    static Result<Integer> order(Product p, int qty) {
        if (qty <= 0) {
            return Result.fail("数量が不正です");
        }
        if (!p.reduce(qty)) {
            return Result.fail("在庫不足です(残り" + p.getStock() + "個)");
        }
        return Result.ok(p.getPrice() * qty + p.shippingFee());
    }

    public static void main(String[] args) {
        List<Product> items = new ArrayList<>(List.of(
                new Product("ノートPC", "PC", 128000, 5),
                new Product("マウス", "周辺機器", 3200, 42),
                new Book("Java入門", 2800, 8),
                new Product("モニター", "周辺機器", 45000, 0)));

        items.forEach(System.out::println);

        int total = items.stream().mapToInt(Product::stockValue).sum();

        Map<String, Integer> byCategory = items.stream()
                .collect(Collectors.groupingBy(
                        Product::getCategory,
                        TreeMap::new,
                        Collectors.summingInt(Product::stockValue)));

        System.out.println("---");
        System.out.println("在庫総額:" + String.format("%,d", total) + "円");
        System.out.println("カテゴリ別:" + byCategory);
        System.out.println("---");

        int[][] requests = {{0, 2}, {1, 0}, {3, 1}};

        for (int[] req : requests) {
            Result<Integer> r = order(items.get(req[0]), req[1]);

            System.out.println(r.isOk()
                    ? "成功:" + String.format("%,d", r.getValue()) + "円"
                    : "失敗:" + r.getError());
        }

        try {
            new Product("不正商品", "PC", -100, 1);
        } catch (IllegalArgumentException e) {
            // 生成が拒否されることを確認(出力はしない)
        }
    }
}
解説

これがJava編の到達点だ。この1本のプログラムに、18章で学んだほぼすべてが入っている。

カプセル化(JV09)がフィールドを守り、継承(JV10)が書籍の送料無料を表現し、インターフェース(JV11)が「配送できる」という性質を定義する。Stream(JV16)が集計を1文にまとめ、ジェネリクス(JV15)の Result型 が失敗を値として返す。例外(JV13)は不正な生成だけに使い、日常的な失敗と区別している。

とくに失敗の表現を2つに使い分けた点に注目してほしい。在庫不足や数量不正はResult型で返し、価格が負というあってはならない値は例外で拒否している。「起きて当然のこと」と「起きてはいけないこと」を区別する——この判断が、システムの堅牢さを決める。

items.forEach(System.out::println) のシンプルさも、積み重ねの成果だ。toString() が表示を、label() が判定を、stockValue() が計算を担っているから、呼び出し側は1行で済む。JV05では main の中にすべてが詰まっていたことを思い出してほしい。

——JV01では System.out.println("Hello, World!") と書くところから始まった。環境構築に手こずり、セミコロンを忘れ、printIn と打ち間違えたかもしれない。それが18章を経て、カプセル化・継承・ジェネリクス・Streamを備えた在庫管理システムになった。

ここまで来たあなたは、もう「Javaを勉強している人」ではありません。Javaでシステムを設計できる人です。次に必要なのは、教材ではなく実際に作りたいものを作ることだ。手を動かした分だけ、確実に上手くなる。

お疲れさまでした。