0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

販売管理システムのケーススタディ 第18章: 在庫管理

0
Posted at

目次

第18章: 在庫管理

18.1 倉庫・棚番マスタ

倉庫ドメインモデルの概要

倉庫は、商品を保管する物理的な場所を管理します。倉庫区分によって用途を分類し、住所情報も管理します。

倉庫コードの値オブジェクト

倉庫コードは、特定の形式を持つ値オブジェクトとして実装されています。

@Value
public class WarehouseCode {
    String value;

    public static WarehouseCode of(String value) {
        if (value == null || value.isEmpty()) {
            throw new IllegalArgumentException("倉庫コードは必須です");
        }
        if (!isValidFormat(value)) {
            throw new IllegalArgumentException(
                "倉庫コードは先頭がW、残り2文字が数字の形式である必要があります(例: W01)");
        }
        return new WarehouseCode(value);
    }

    private static boolean isValidFormat(String value) {
        if (value.length() != 3) {
            return false;
        }
        if (value.charAt(0) != 'W') {
            return false;
        }
        return Character.isDigit(value.charAt(1)) && Character.isDigit(value.charAt(2));
    }
}

倉庫区分

倉庫の用途を分類する列挙型です。

@Getter
public enum WarehouseCategory {
    通常倉庫("N", "通常倉庫"),
    得意先("C", "得意先"),
    仕入先("S", "仕入先"),
    部門倉庫("D", "部門倉庫"),
    製品倉庫("P", "製品倉庫"),
    原材料倉庫("M", "原材料倉庫");

    private final String code;
    private final String description;

    WarehouseCategory(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static WarehouseCategory of(String code) {
        if (code == null) {
            return null;
        }
        for (WarehouseCategory category : WarehouseCategory.values()) {
            if (category.getCode().equals(code)) {
                return category;
            }
        }
        throw new IllegalArgumentException("不正な倉庫区分です: " + code);
    }
}

倉庫エンティティの実装

@Value
public class Warehouse {
    WarehouseCode warehouseCode;
    String warehouseName;
    WarehouseCategory warehouseCategory;
    Address address;

    public static Warehouse of(WarehouseCode warehouseCode, String warehouseName,
                                WarehouseCategory warehouseCategory, Address address) {
        return new Warehouse(warehouseCode, warehouseName, warehouseCategory, address);
    }

    public static Warehouse of(String warehouseCode, String warehouseName,
                                String warehouseCategory, String postalCode,
                                String prefecture, String address1, String address2) {
        Address warehouseAddress = null;
        if (postalCode != null && prefecture != null && address1 != null && address2 != null) {
            warehouseAddress = Address.of(postalCode, prefecture, address1, address2);
        }
        return new Warehouse(
                WarehouseCode.of(warehouseCode),
                warehouseName,
                WarehouseCategory.of(warehouseCategory),
                warehouseAddress);
    }
}

18.2 在庫データの実装

在庫ドメインモデルの概要

在庫は、倉庫・商品・ロット・区分の組み合わせで一意に識別されます。複合主キー(InventoryKey)によって管理されます。

在庫エンティティの実装

@Value
@AllArgsConstructor
@NoArgsConstructor(force = true)
@Builder(toBuilder = true)
public class Inventory {
    WarehouseCode warehouseCode; // 倉庫コード
    ProductCode productCode; // 商品コード
    LotNumber lotNumber; // ロット番号
    StockCategory stockCategory; // 在庫区分
    QualityCategory qualityCategory; // 良品区分
    Quantity actualStockQuantity; // 実在庫数
    Quantity availableStockQuantity; // 有効在庫数
    LocalDateTime lastShipmentDate; // 最終出荷日
    String productName; // 商品名
    String warehouseName; // 倉庫名

    public static Inventory of(
            String warehouseCode,
            String productCode,
            String lotNumber,
            String stockCategory,
            String qualityCategory,
            Integer actualStockQuantity,
            Integer availableStockQuantity,
            LocalDateTime lastShipmentDate) {

        notNull(warehouseCode, "倉庫コードは必須です");
        notNull(productCode, "商品コードは必須です");
        notNull(lotNumber, "ロット番号は必須です");
        notNull(stockCategory, "在庫区分は必須です");
        notNull(qualityCategory, "良品区分は必須です");
        notNull(actualStockQuantity, "実在庫数は必須です");
        notNull(availableStockQuantity, "有効在庫数は必須です");

        isTrue(actualStockQuantity >= 0, "実在庫数は0以上である必要があります");
        isTrue(availableStockQuantity >= 0, "有効在庫数は0以上である必要があります");
        isTrue(availableStockQuantity <= actualStockQuantity,
               "有効在庫数は実在庫数以下である必要があります");

        return Inventory.builder()
                .warehouseCode(WarehouseCode.of(warehouseCode))
                .productCode(ProductCode.of(productCode))
                .lotNumber(LotNumber.of(lotNumber))
                .stockCategory(StockCategory.of(stockCategory))
                .qualityCategory(QualityCategory.of(qualityCategory))
                .actualStockQuantity(Quantity.of(actualStockQuantity))
                .availableStockQuantity(Quantity.of(availableStockQuantity))
                .lastShipmentDate(lastShipmentDate)
                .build();
    }
}

複合主キーの実装

在庫は5つの属性の組み合わせで一意に識別されます。

@Value
@AllArgsConstructor
public class InventoryKey {
    String warehouseCode; // 倉庫コード
    String productCode; // 商品コード
    String lotNumber; // ロット番号
    String stockCategory; // 在庫区分
    String qualityCategory; // 良品区分

    public static InventoryKey of(
            String warehouseCode,
            String productCode,
            String lotNumber,
            String stockCategory,
            String qualityCategory) {

        notBlank(warehouseCode, "倉庫コードは必須です");
        notBlank(productCode, "商品コードは必須です");
        notBlank(lotNumber, "ロット番号は必須です");
        notBlank(stockCategory, "在庫区分は必須です");
        notBlank(qualityCategory, "良品区分は必須です");

        return new InventoryKey(
                warehouseCode,
                productCode,
                lotNumber,
                stockCategory,
                qualityCategory
        );
    }

    @Override
    public String toString() {
        return String.format("%s-%s-%s-%s-%s",
                warehouseCode,
                productCode,
                lotNumber,
                stockCategory,
                qualityCategory);
    }
}

ロット番号の値オブジェクト

ロット番号は、製造ロットや入荷ロットを識別するための値オブジェクトです。

@Value
public class LotNumber {
    String value;

    public static LotNumber of(String value) {
        if (value == null || value.isEmpty()) {
            throw new IllegalArgumentException("ロット番号は必須です");
        }
        if (value.length() > 20) {
            throw new IllegalArgumentException("ロット番号は20文字以下である必要があります");
        }
        // ロット番号の形式チェック(英数字とハイフンのみ許可)
        if (!value.matches("^[A-Za-z0-9\\-]+$")) {
            throw new IllegalArgumentException("ロット番号は英数字とハイフンのみ使用できます");
        }
        return new LotNumber(value);
    }

    @Override
    public String toString() {
        return value;
    }
}

在庫操作メソッド

在庫エンティティには、入出荷や予約などの操作メソッドが実装されています。

/**
 * 在庫を調整
 */
public Inventory adjustStock(Integer adjustmentQuantity) {
    Quantity adjustment = Quantity.of(Math.abs(adjustmentQuantity));
    Quantity newActualStock = adjustmentQuantity >= 0
        ? actualStockQuantity.plus(adjustment)
        : actualStockQuantity.minus(adjustment);
    Quantity newAvailableStock = adjustmentQuantity >= 0
        ? availableStockQuantity.plus(adjustment)
        : availableStockQuantity.minus(adjustment);

    isTrue(newActualStock.getAmount() >= 0, "調整後の実在庫数が負になります");
    isTrue(newAvailableStock.getAmount() >= 0, "調整後の有効在庫数が負になります");

    return this.toBuilder()
            .actualStockQuantity(newActualStock)
            .availableStockQuantity(newAvailableStock)
            .build();
}

/**
 * 在庫を予約
 */
public Inventory reserve(Integer reserveQuantity) {
    isTrue(reserveQuantity > 0, "予約数量は正の数である必要があります");
    isTrue(availableStockQuantity.getAmount() >= reserveQuantity,
           "有効在庫数が不足しています");

    Quantity reserve = Quantity.of(reserveQuantity);
    return this.toBuilder()
            .availableStockQuantity(availableStockQuantity.minus(reserve))
            .build();
}

/**
 * 在庫を出荷
 */
public Inventory ship(Integer shipmentQuantity, LocalDateTime shipmentDate) {
    isTrue(shipmentQuantity > 0, "出荷数量は正の数である必要があります");
    isTrue(actualStockQuantity.getAmount() >= shipmentQuantity,
           "実在庫数が不足しています");

    Quantity shipment = Quantity.of(shipmentQuantity);
    Quantity newActualStock = actualStockQuantity.minus(shipment);
    Quantity newAvailableStock = Quantity.of(
        Math.min(availableStockQuantity.getAmount(), newActualStock.getAmount()));

    return this.toBuilder()
            .actualStockQuantity(newActualStock)
            .availableStockQuantity(newAvailableStock)
            .lastShipmentDate(shipmentDate)
            .build();
}

/**
 * 在庫を入荷
 */
public Inventory receive(Integer receiptQuantity) {
    isTrue(receiptQuantity > 0, "入荷数量は正の数である必要があります");

    Quantity receipt = Quantity.of(receiptQuantity);
    return this.toBuilder()
            .actualStockQuantity(actualStockQuantity.plus(receipt))
            .availableStockQuantity(availableStockQuantity.plus(receipt))
            .build();
}

在庫区分と良品区分

/**
 * 在庫区分
 */
@Getter
public enum StockCategory {
    通常在庫("1", "通常の在庫"),
    安全在庫("2", "安全在庫(最低保持数量)"),
    廃棄予定("3", "廃棄予定在庫");

    private final String code;
    private final String description;

    StockCategory(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static StockCategory of(String code) {
        for (StockCategory stockCategory : StockCategory.values()) {
            if (stockCategory.getCode().equals(code)) {
                return stockCategory;
            }
        }
        throw new IllegalArgumentException("不正な在庫区分です: " + code);
    }
}

/**
 * 良品区分
 */
@Getter
public enum QualityCategory {
    良品("G", "良品(正常品質)"),
    不良品("B", "不良品(品質不良)"),
    返品("R", "返品(お客様返品)");

    private final String code;
    private final String description;

    QualityCategory(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static QualityCategory of(String code) {
        for (QualityCategory qualityCategory : QualityCategory.values()) {
            if (qualityCategory.getCode().equals(code)) {
                return qualityCategory;
            }
        }
        throw new IllegalArgumentException("不正な良品区分です: " + code);
    }
}

18.3 一括登録

CSVファイルによる在庫一括登録

大量の在庫データを効率的に登録するため、CSVファイルによる一括アップロード機能を提供しています。

CSVファイルフォーマット

在庫データのCSVファイルは、以下のフォーマットで作成します。

列番号 カラム名 データ型 必須 説明
0 倉庫コード 文字列 W + 数字2桁(例: W01)
1 商品コード 文字列 16桁以内
2 ロット番号 文字列 英数字とハイフン、20文字以内
3 在庫区分 文字列 1: 通常在庫, 2: 安全在庫, 3: 廃棄予定
4 良品区分 文字列 G: 良品, B: 不良品, R: 返品
5 実在庫数量 整数 0以上
6 有効在庫数量 整数 0以上、実在庫数以下

CSVファイル例:

W01,10101001,LOT001,1,G,100,95
W01,10101002,LOT001,1,G,50,50
W02,10101001,LOT002,2,G,30,30
W01,10101003,LOT001,1,B,10,0

CSVマッピングクラス

OpenCSVを使用してCSVファイルをJavaオブジェクトにマッピングします。

@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonPropertyOrder({
    "warehouseCode", "productCode", "lotNumber",
    "stockCategory", "qualityCategory",
    "actualStockQuantity", "availableStockQuantity"
})
public class InventoryUploadCSV {

    /** 倉庫コード */
    @CsvBindByPosition(position = 0)
    private String warehouseCode;

    /** 商品コード */
    @CsvBindByPosition(position = 1)
    private String productCode;

    /** ロット番号 */
    @CsvBindByPosition(position = 2)
    private String lotNumber;

    /** 在庫区分 */
    @CsvBindByPosition(position = 3)
    private String stockCategory;

    /** 良品区分 */
    @CsvBindByPosition(position = 4)
    private String qualityCategory;

    /** 実在庫数量 */
    @CsvBindByPosition(position = 5)
    private String actualStockQuantity;

    /** 有効在庫数量 */
    @CsvBindByPosition(position = 6)
    private String availableStockQuantity;
}

サービス層の実装

/**
 * 在庫一括登録
 */
public InventoryUploadErrorList uploadCsvFile(MultipartFile file) {
    // ファイル検証
    notNull(file, "アップロードファイルは必須です。");
    isTrue(!file.isEmpty(), "CSVファイルの読み込みに失敗しました");

    String originalFilename = Optional.ofNullable(file.getOriginalFilename())
            .orElseThrow(() -> new IllegalArgumentException("アップロードファイル名は必須です。"));
    isTrue(originalFilename.endsWith(".csv"), "アップロードファイルがCSVではありません。");
    isTrue(file.getSize() < 1000, "アップロードファイルが大きすぎます。");

    log.info("在庫CSVアップロード開始: ファイル名={}, サイズ={}",
             originalFilename, file.getSize());

    // CSVパース
    Pattern2ReadCSVUtil<InventoryUploadCSV> csvUtil = new Pattern2ReadCSVUtil<>();
    List<InventoryUploadCSV> dataList = csvUtil.readCSV(
        InventoryUploadCSV.class, file, "UTF-8");
    isTrue(!dataList.isEmpty(), "CSVファイルの読み込みに失敗しました");

    // データ検証
    InventoryUploadErrorList errorList = validateErrors(dataList);
    if (!errorList.isEmpty()) {
        log.warn("在庫CSVアップロードバリデーションエラー: エラー数={}",
                 errorList.size());
        return errorList;
    }

    // CSV データをドメインオブジェクトに変換して保存
    List<Inventory> inventoryList = convertFromCsv(dataList);
    List<Map<String, String>> saveErrors = new ArrayList<>();

    for (Inventory inventory : inventoryList) {
        try {
            inventoryRepository.save(inventory);
            log.debug("在庫登録成功: {}", inventory.getKey());
        } catch (Exception e) {
            log.error("在庫登録エラー: key={}, error={}",
                      inventory.getKey(), e.getMessage());
            Map<String, String> error = new HashMap<>();
            error.put(inventory.getKey().toString(), e.getMessage());
            saveErrors.add(error);
        }
    }

    return new InventoryUploadErrorList(saveErrors);
}

バリデーション処理

各項目に対してドメインルールに基づいた検証を行います。

/**
 * アップロードデータの検証
 */
private InventoryUploadErrorList validateErrors(List<InventoryUploadCSV> dataList) {
    List<Map<String, String>> errors = new ArrayList<>();
    int rowNum = 1;

    for (InventoryUploadCSV csv : dataList) {
        final int currentRow = rowNum;

        BiConsumer<String, String> addError = (field, message) -> {
            Map<String, String> errorMap = new HashMap<>();
            errorMap.put("行" + currentRow, field + ": " + message);
            errors.add(errorMap);
        };

        // 倉庫コード検証
        if (csv.getWarehouseCode() == null || csv.getWarehouseCode().isEmpty()) {
            addError.accept("倉庫コード", "必須項目です");
        } else {
            try {
                WarehouseCode.of(csv.getWarehouseCode());
            } catch (IllegalArgumentException e) {
                addError.accept("倉庫コード", e.getMessage());
            }
        }

        // 商品コード検証
        if (csv.getProductCode() == null || csv.getProductCode().isEmpty()) {
            addError.accept("商品コード", "必須項目です");
        }

        // ロット番号検証
        if (csv.getLotNumber() == null || csv.getLotNumber().isEmpty()) {
            addError.accept("ロット番号", "必須項目です");
        } else {
            try {
                LotNumber.of(csv.getLotNumber());
            } catch (IllegalArgumentException e) {
                addError.accept("ロット番号", e.getMessage());
            }
        }

        // 在庫区分検証
        if (csv.getStockCategory() == null || csv.getStockCategory().isEmpty()) {
            addError.accept("在庫区分", "必須項目です");
        } else {
            try {
                StockCategory.of(csv.getStockCategory());
            } catch (IllegalArgumentException e) {
                addError.accept("在庫区分", e.getMessage());
            }
        }

        // 良品区分検証
        if (csv.getQualityCategory() == null || csv.getQualityCategory().isEmpty()) {
            addError.accept("良品区分", "必須項目です");
        } else {
            try {
                QualityCategory.of(csv.getQualityCategory());
            } catch (IllegalArgumentException e) {
                addError.accept("良品区分", e.getMessage());
            }
        }

        // 数量検証
        validateQuantity(csv.getActualStockQuantity(), "実在庫数量", addError);
        validateQuantity(csv.getAvailableStockQuantity(), "有効在庫数量", addError);

        // 有効在庫数 <= 実在庫数のチェック
        if (isValidInteger(csv.getActualStockQuantity()) &&
            isValidInteger(csv.getAvailableStockQuantity())) {
            int actual = Integer.parseInt(csv.getActualStockQuantity());
            int available = Integer.parseInt(csv.getAvailableStockQuantity());
            if (available > actual) {
                addError.accept("有効在庫数量", "実在庫数量以下である必要があります");
            }
        }

        rowNum++;
    }

    return new InventoryUploadErrorList(errors);
}

エラーリストの設計

アップロード時のエラーは、行番号とエラー内容を含むリストとして返却します。

/**
 * 在庫アップロードエラーリスト
 */
public class InventoryUploadErrorList {
    List<Map<String, String>> value;

    public InventoryUploadErrorList(List<Map<String, String>> value) {
        this.value = Collections.unmodifiableList(value);
    }

    public int size() {
        return value.size();
    }

    public List<Map<String, String>> asList() {
        return value;
    }

    public boolean isEmpty() {
        return value.isEmpty();
    }
}

APIエンドポイント

@Operation(summary = "在庫を一括登録する", description = "ファイルアップロードで在庫を登録する")
@PostMapping("/upload")
@AuditAnnotation(process = ApplicationExecutionProcessType.在庫登録,
                 type = ApplicationExecutionHistoryType.同期)
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
    try {
        InventoryUploadErrorList result = inventoryService.uploadCsvFile(file);
        if (result.isEmpty()) {
            return ResponseEntity.ok(new MessageResponseWithDetail(
                message.getMessage("success.inventory.upload"),
                result.asList()));
        }
        return ResponseEntity.ok(new MessageResponseWithDetail(
            message.getMessage("error.inventory.upload"),
            result.asList()));
    } catch (RuntimeException e) {
        return ResponseEntity.badRequest().body(
            new MessageResponseWithDetail(e.getMessage(), List.of()));
    }
}

React コンポーネントの実装

一括登録画面は、Container/View パターンで実装されています。

アップロードモーダル

ファイル選択とアップロード実行を行うモーダルダイアログです。

export const InventoryUploadSingle: React.FC = () => {
    const {
        message, setMessage,
        error, setError,
        setUploadModalIsOpen,
        uploadInventories,
    } = useInventoryContext();
    const [selectedFile, setSelectedFile] = useState<File | null>(null);

    const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
        if (event.target.files && event.target.files[0]) {
            setSelectedFile(event.target.files[0]);
        }
    };

    const handleUpload = async () => {
        setError("");
        setMessage("");
        if (!selectedFile) {
            setError("ファイルを選択してください");
            return;
        }
        try {
            await uploadInventories(selectedFile);
            setUploadModalIsOpen(false);
            setSelectedFile(null);
        } catch (error) {
            const errorMessage = error instanceof Error
                ? error.message
                : "アップロード中にエラーが発生しました";
            setError(errorMessage);
        }
    };

    const handleCloseModal = () => {
        setError("");
        setMessage("");
        setUploadModalIsOpen(false);
        setSelectedFile(null);
    };

    return (
        <InventoryUploadSingleView
            error={error}
            message={message}
            onFileSelect={handleFileSelect}
            onUpload={handleUpload}
            onClose={handleCloseModal}
            isUploadDisabled={!selectedFile}
        />
    );
};

アップロード結果の表示

アップロード結果(成功・エラー)を一覧表示します。

export const InventoryUploadCollection: React.FC = () => {
    const { uploadResults, setUploadResults, setUploadModalIsOpen } = useInventoryContext();

    const handleOpenUploadModal = () => {
        setUploadModalIsOpen(true);
    };

    const handleDeleteUploadResult = (index: number) => {
        setUploadResults((prev: any[]) => prev.filter((_: any, i: number) => i !== index));
    };

    return (
        <>
            <InventoryUploadCollectionView
                uploadHeaderItems={{ handleOpenUploadModal }}
                uploadResults={uploadResults}
                handleDeleteUploadResult={handleDeleteUploadResult}
            />
            <InventoryUploadModal/>
        </>
    );
};

18.4 在庫ルール

Strategy パターンによるルール実装

在庫ルールも他のドメインと同様に、Strategy パターンで実装されています。

/**
 * 在庫ルール(抽象基底クラス)
 */
public abstract class InventoryRule {
    public abstract boolean isSatisfiedBy(Inventory inventory);
}

在庫レベルルール

在庫が閾値を下回った場合に警告を出すルールです。

/**
 * 在庫レベルルール
 * 実在庫数が閾値を下回っている場合は要確認とする
 */
public class InventoryStockLevelRule extends InventoryRule {

    private static final int LOW_STOCK_THRESHOLD = 10;

    @Override
    public boolean isSatisfiedBy(Inventory inventory) {
        return inventory.getActualStockQuantity().getAmount() < LOW_STOCK_THRESHOLD;
    }
}

在庫ドメインサービス

複数のルールを適用してチェック結果を返します。

@Service
public class InventoryDomainService {

    /**
     * 在庫ルールチェック
     */
    public InventoryRuleCheckList checkRule(InventoryList inventories) {
        List<Map<String, String>> checkList = new ArrayList<>();

        List<Inventory> inventoryList = inventories.asList();
        InventoryRule inventoryStockLevelRule = new InventoryStockLevelRule();
        InventoryRule inventoryZeroStockRule = new InventoryZeroStockRule();

        BiConsumer<String, String> addCheck = (inventoryKey, message) -> {
            Map<String, String> errorMap = new HashMap<>();
            errorMap.put(inventoryKey, message);
            checkList.add(errorMap);
        };

        inventoryList.forEach(inventory -> {
            String inventoryKey = inventory.getKey().getWarehouseCode() +
                                "-" + inventory.getKey().getProductCode() +
                                "-" + inventory.getKey().getLotNumber();

            // 在庫レベルルールチェック
            if (inventoryStockLevelRule.isSatisfiedBy(inventory)) {
                addCheck.accept(inventoryKey, "在庫数量が不足しています。");
            }

            // 在庫ゼロルールチェック
            if (inventoryZeroStockRule.isSatisfiedBy(inventory)) {
                addCheck.accept(inventoryKey, "在庫数量がゼロです。");
            }
        });

        return new InventoryRuleCheckList(checkList);
    }
}

在庫サービスの実装

在庫サービスは、CRUD 操作と入出荷処理を提供します。

@Service
@Transactional
@Slf4j
public class InventoryService {

    private final InventoryRepository inventoryRepository;
    private final InventoryDomainService inventoryDomainService;

    /**
     * 在庫登録
     */
    public Inventory register(Inventory inventory) {
        notNull(inventory, "在庫情報は必須です");

        log.info("在庫登録開始: {}", inventory.getKey());

        // 既に同じキーの在庫が存在するかチェック
        Optional<Inventory> existing = inventoryRepository.findByKey(inventory.getKey());
        if (existing.isPresent()) {
            throw new IllegalArgumentException("既に存在する在庫キーです: " + inventory.getKey());
        }

        Inventory saved = inventoryRepository.save(inventory);
        log.info("在庫登録完了: {}", saved.getKey());

        return saved;
    }

    /**
     * 在庫出荷
     */
    public Inventory shipStock(InventoryKey key, Integer shipmentQuantity,
                                LocalDateTime shipmentDate) {
        notNull(key, "在庫キーは必須です");
        notNull(shipmentQuantity, "出荷数量は必須です");
        notNull(shipmentDate, "出荷日時は必須です");

        log.info("在庫出荷開始: key={}, 出荷数量={}, 出荷日時={}",
                key, shipmentQuantity, shipmentDate);

        Inventory inventory = inventoryRepository.findByKey(key)
                .orElseThrow(() -> new IllegalArgumentException("在庫が見つかりません: " + key));

        Inventory shipped = inventory.ship(shipmentQuantity, shipmentDate);
        Inventory saved = inventoryRepository.save(shipped);

        log.info("在庫出荷完了: key={}, 出荷前実在庫={}, 出荷後実在庫={}",
                key, inventory.getActualStockQuantity(), saved.getActualStockQuantity());

        return saved;
    }

    /**
     * 出荷完了イベントを処理して在庫を減らす
     */
    public Inventory processShipment(Shipped event) {
        notNull(event, "出荷イベントは必須です");

        log.info("出荷イベント処理開始: {}", event);

        ShippingAggregate shipping = event.getShipping();

        // InventoryKey を構築
        InventoryKey inventoryKey = InventoryKey.of(
                shipping.getWarehouseCode(),
                shipping.getProductCode().getValue(),
                "DEFAULT_LOT",
                "1",
                "1"
        );

        Integer shipmentQuantity = shipping.getShippedQuantity().getAmount();
        LocalDateTime shipmentDateTime = shipping.getShippingDate() != null ?
                shipping.getShippingDate().getValue() :
                LocalDateTime.now();

        Inventory result = shipStock(inventoryKey, shipmentQuantity, shipmentDateTime);

        log.info("出荷イベント処理完了: キー={}, 出荷数量={}", inventoryKey, shipmentQuantity);

        return result;
    }

    /**
     * 在庫ルールチェック
     */
    public InventoryRuleCheckList checkRule() {
        InventoryList inventories = inventoryRepository.selectAll();
        return inventoryDomainService.checkRule(inventories);
    }
}

18.5 React コンポーネントの実装

在庫画面のコンポーネント構成

在庫管理のフロントエンド実装は、React を使用してコンポーネントベースで構築されています。

在庫一覧画面の実装

在庫一覧画面は、複合主キー(倉庫コード・商品コード・ロット番号・在庫区分・良品区分)で在庫データを管理します。

interface InventoryItemProps {
    inventory: InventoryType;
    onEdit: (inventory: InventoryType) => void;
    onDelete: (
        warehouseCode: string,
        productCode: string,
        lotNumber: string,
        stockCategory: string,
        qualityCategory: string
    ) => void;
    onCheck: (inventory: InventoryType) => void;
}

const InventoryItem: React.FC<InventoryItemProps> = ({
    inventory, onEdit, onDelete, onCheck
}) => {
    // 在庫区分ラベル変換
    const getStockCategoryLabel = (code: string) => {
        switch (code) {
            case "1": return "通常在庫";
            case "2": return "安全在庫";
            case "3": return "廃棄予定";
            default: return code;
        }
    };

    // 良品区分ラベル変換
    const getQualityCategoryLabel = (code: string) => {
        switch (code) {
            case "G": return "良品";
            case "B": return "不良品";
            case "R": return "返品";
            default: return code;
        }
    };

    // 複合主キーでユニークキーを生成
    const inventoryKey = `${inventory.warehouseCode}-${inventory.productCode}-` +
                        `${inventory.lotNumber}-${inventory.stockCategory}-` +
                        `${inventory.qualityCategory}`;

    return (
        <li className="collection-object-item" key={inventoryKey}>
            <div className="collection-object-item-content">
                <input
                    type="checkbox"
                    checked={inventory.checked}
                    onChange={() => onCheck(inventory)}
                />
            </div>
            <div className="collection-object-item-content">
                <div className="collection-object-item-content-details">倉庫コード</div>
                <div className="collection-object-item-content-name">
                    {inventory.warehouseCode}
                </div>
            </div>
            <div className="collection-object-item-content">
                <div className="collection-object-item-content-details">商品コード</div>
                <div className="collection-object-item-content-name">
                    {inventory.productCode}
                </div>
            </div>
            <div className="collection-object-item-content">
                <div className="collection-object-item-content-details">ロット番号</div>
                <div className="collection-object-item-content-name">
                    {inventory.lotNumber}
                </div>
            </div>
            <div className="collection-object-item-content">
                <div className="collection-object-item-content-details">在庫区分</div>
                <div className="collection-object-item-content-name">
                    {getStockCategoryLabel(inventory.stockCategory)}
                </div>
            </div>
            <div className="collection-object-item-content">
                <div className="collection-object-item-content-details">実在庫数</div>
                <div className="collection-object-item-content-name">
                    {inventory.actualStockQuantity?.toLocaleString()}
                </div>
            </div>
            <div className="collection-object-item-actions">
                <button onClick={() => onEdit(inventory)}>編集</button>
            </div>
            <div className="collection-object-item-actions">
                <button onClick={() => onDelete(
                    inventory.warehouseCode,
                    inventory.productCode,
                    inventory.lotNumber,
                    inventory.stockCategory,
                    inventory.qualityCategory
                )}>削除</button>
            </div>
        </li>
    );
};

在庫詳細フォームの実装

在庫詳細フォームでは、倉庫と商品をモーダルから選択し、区分ドロップダウンで在庫区分・良品区分を設定します。

interface FormProps {
    isEditing: boolean;
    inventory: InventoryType;
    setInventory: React.Dispatch<React.SetStateAction<InventoryType>>;
    handleWarehouseSelect?: () => void;
    handleProductSelect?: () => void;
}

const Form = ({
    isEditing,
    inventory,
    setInventory,
    handleWarehouseSelect,
    handleProductSelect
}: FormProps) => {
    return (
        <div className="single-view-content-item-form">
            <FormInput
                label="倉庫コード"
                id="warehouseCode"
                type="text"
                value={inventory.warehouseCode}
                onChange={(e) => setInventory({
                    ...inventory,
                    warehouseCode: e.target.value
                })}
                onClick={handleWarehouseSelect}
                disabled={isEditing}
            />
            <FormInput
                label="商品コード"
                id="productCode"
                type="text"
                value={inventory.productCode}
                onChange={(e) => setInventory({
                    ...inventory,
                    productCode: e.target.value
                })}
                onClick={handleProductSelect}
                disabled={isEditing}
            />
            <FormInput
                label="ロット番号"
                id="lotNumber"
                type="text"
                value={inventory.lotNumber}
                onChange={(e) => setInventory({
                    ...inventory,
                    lotNumber: e.target.value
                })}
                disabled={isEditing}
            />
            <div className="single-view-content-item-form-item">
                <label htmlFor="stockCategory">在庫区分</label>
                <select
                    id="stockCategory"
                    value={inventory.stockCategory}
                    onChange={(e) => setInventory({
                        ...inventory,
                        stockCategory: e.target.value
                    })}
                    disabled={isEditing}
                >
                    <option value={StockCategoryEnumType.通常在庫}>通常在庫</option>
                    <option value={StockCategoryEnumType.安全在庫}>安全在庫</option>
                    <option value={StockCategoryEnumType.廃棄予定}>廃棄予定</option>
                </select>
            </div>
            <div className="single-view-content-item-form-item">
                <label htmlFor="qualityCategory">良品区分</label>
                <select
                    id="qualityCategory"
                    value={inventory.qualityCategory}
                    onChange={(e) => setInventory({
                        ...inventory,
                        qualityCategory: e.target.value
                    })}
                    disabled={isEditing}
                >
                    <option value={QualityCategoryEnumType.良品}>良品</option>
                    <option value={QualityCategoryEnumType.不良品}>不良品</option>
                    <option value={QualityCategoryEnumType.返品}>返品</option>
                </select>
            </div>
            <FormInput
                label="実在庫数"
                id="actualStockQuantity"
                type="number"
                value={inventory.actualStockQuantity}
                onChange={(e) => setInventory({
                    ...inventory,
                    actualStockQuantity: Number(e.target.value)
                })}
            />
            <FormInput
                label="有効在庫数"
                id="availableStockQuantity"
                type="number"
                value={inventory.availableStockQuantity}
                onChange={(e) => setInventory({
                    ...inventory,
                    availableStockQuantity: Number(e.target.value)
                })}
            />
            <FormInput
                label="最終出荷日"
                id="lastShipmentDate"
                type="date"
                value={inventory.lastShipmentDate || ''}
                onChange={(e) => setInventory({
                    ...inventory,
                    lastShipmentDate: e.target.value
                })}
            />
        </div>
    );
};

マスタ選択モーダルの連携

在庫画面では、倉庫と商品をモーダルから選択できます。

型定義

在庫管理で使用する TypeScript の型定義を示します。

export enum StockCategoryEnumType {
    通常在庫 = "1",
    安全在庫 = "2",
    廃棄予定 = "3"
}

export enum QualityCategoryEnumType {
    良品 = "G",
    不良品 = "B",
    返品 = "R"
}

export type InventoryType = {
    warehouseCode: string;
    productCode: string;
    lotNumber: string;
    stockCategory: string;
    qualityCategory: string;
    actualStockQuantity: number;
    availableStockQuantity: number;
    lastShipmentDate?: string;
    productName?: string;
    warehouseName?: string;
    checked?: boolean;
};

export type InventoryCriteriaType = {
    warehouseCode?: string;
    productCode?: string;
    lotNumber?: string;
    stockCategory?: string;
    qualityCategory?: string;
    productName?: string;
    warehouseName?: string;
    hasStock?: boolean;
    isAvailable?: boolean;
};

在庫コンテキストによる状態管理

在庫画面は、React Context を使用して状態管理を行います。

export const InventorySingle: React.FC = () => {
    const {
        message,
        setMessage,
        error,
        setError,
        isEditing,
        newInventory,
        setNewInventory,
        inventoryService,
        fetchInventories,
        setModalIsOpen,
    } = useInventoryContext();

    const { setModalIsOpen: setWarehouseModalIsOpen } = useWarehouseContext();
    const { setModalIsOpen: setProductModalIsOpen } = useProductItemContext();

    const handleCloseModal = () => {
        setModalIsOpen(false);
    };

    const handleWarehouseSelect = () => {
        setWarehouseModalIsOpen(true);
    };

    const handleProductSelect = () => {
        setProductModalIsOpen(true);
    };

    const handleSaveInventory = async () => {
        try {
            if (isEditing) {
                await inventoryService.update(newInventory);
                setMessage("在庫データを更新しました。");
            } else {
                await inventoryService.create(newInventory);
                setMessage("在庫データを登録しました。");
            }
            await fetchInventories.load();
            setModalIsOpen(false);
        } catch (error: any) {
            showErrorMessage(
                `在庫データの${isEditing ? '更新' : '登録'}に失敗しました: ${error?.message}`,
                setError
            );
        }
    };

    return (
        <InventoryEditModalView
            isOpen={true}
            onClose={handleCloseModal}
            isEditing={isEditing}
            inventory={newInventory}
            setInventory={setNewInventory}
            onSave={handleSaveInventory}
            error={error}
            message={message}
            handleWarehouseSelect={handleWarehouseSelect}
            handleProductSelect={handleProductSelect}
        />
    );
};

18.6 E2E テスト

Cypress によるテスト

在庫管理の E2E テストは、Cypress を使って実装されています。管理者と利用者の両方のシナリオをテストします。

describe('在庫管理', () => {
    context('管理者', () => {
        beforeEach(() => {
            cy.login('U000003', 'a234567Z');
        })

        const inventoryPage = () => {
            cy.get('#side-nav-menu > :nth-child(1) > :nth-child(3) > :nth-child(3) ' +
                   '> .nav-sub-list > :nth-child(1) > #side-nav-inventory-nav').click();
        }

        context('在庫一覧', () => {
            it('在庫一覧の表示', () => {
                inventoryPage();
                cy.get('.collection-view-container').should('be.visible');
            });
        });

        context('在庫新規登録', () => {
            it('新規登録', () => {
                inventoryPage();

                // 在庫新規画面を開く
                cy.get('#new').click();

                // 倉庫情報を入力
                cy.get('#warehouseCode').click();
                cy.get(':nth-child(1) > .collection-object-item-actions ' +
                       '> #select-warehouse').click();

                // 商品情報を入力
                cy.get('#productCode').click();
                cy.get(':nth-child(1) > .collection-object-item-actions ' +
                       '> #select-product').click();

                // ロット番号を入力
                cy.get('#lotNumber').type('LOT001');

                // 在庫区分を選択
                cy.get('#stockCategory').select('通常在庫');

                // 良品区分を選択
                cy.get('#qualityCategory').select('良品');

                // 在庫数量を入力
                cy.get('#actualStockQuantity').type('100');
                cy.get('#availableStockQuantity').type('95');

                // 最終出荷日を入力
                cy.get('#lastShipmentDate').type('2024-01-15');

                // 在庫を保存
                cy.get('#save').click();

                // 作成完了メッセージの確認
                cy.get('#message').contains('在庫データを登録しました。');
            });
        });
    });
});

テストシナリオの設計

E2E テストは、以下のシナリオを網羅しています。

在庫ルールチェックのテスト

describe('在庫管理', () => {
    context('管理者', () => {
        beforeEach(() => {
            cy.login('U000003', 'a234567Z');

            // 事前にアップロードしてルールチェック対象データを作成
            cy.get('#side-nav-menu > :nth-child(1) > :nth-child(3) > :nth-child(3) ' +
                   '> .nav-sub-list > :nth-child(2) > #side-nav-inventory-nav').click();
            cy.get('#upload').should('be.visible');
            cy.get('#upload').click();
            cy.get('.modal').should('be.visible');
            cy.uploadFile('input[type="file"]', 'valid_inventory.csv', 'text/csv');
            cy.get('.modal button').contains('アップロード').click();
            cy.get('p').contains('在庫アップロードが完了しました');
        })

        const inventoryRulePage = () => {
            cy.get('#side-nav-menu > :nth-child(1) > :nth-child(3) > :nth-child(3) ' +
                   '> .nav-sub-list > :nth-child(3) > #side-nav-inventory-nav').click();
        }

        context('在庫ルール', () => {
            it('実行', () => {
                inventoryRulePage();
                cy.get('#execute').should('be.visible');
                cy.get('#execute').click();
                cy.get('p').contains('在庫ルールチェックを実行しました');
            });

            it('在庫不足のルールチェック', () => {
                // まず在庫不足データをアップロード
                cy.get('#side-nav-menu > :nth-child(1) > :nth-child(3) > :nth-child(3) ' +
                       '> .nav-sub-list > :nth-child(2) > #side-nav-inventory-nav').click();
                cy.get('#upload').click();
                cy.uploadFile('input[type="file"]', 'inventory_valid_for_check.csv', 'text/csv');
                cy.get('.modal button').contains('アップロード').click();

                // ルールページに移動
                inventoryRulePage();

                // ルールチェック実行
                cy.get('#execute').click();
                cy.get('p').contains('在庫ルールチェックを実行しました');

                // 在庫不足の警告が出ることを確認
                cy.get('p').should('contain', '確認項目があります');
            });
        });
    });
});

テストデータの準備

E2E テストでは、CSV ファイルを使ってテストデータを準備します。

まとめ

この章では、在庫管理の実装について解説しました。

重要なポイント:

  1. 倉庫マスタ: 倉庫コード(W01形式)と倉庫区分(通常倉庫、製品倉庫、原材料倉庫など)で倉庫を管理します。

  2. 複合主キー: 在庫は、倉庫コード・商品コード・ロット番号・在庫区分・良品区分の5つの属性で一意に識別されます。これにより、同じ商品でも異なるロットや品質区分で別々に管理できます。

  3. 一括登録: CSV ファイルによる在庫データの一括アップロードに対応。OpenCSV でのマッピング、ドメインルールに基づくバリデーション、エラー詳細の返却を実装しています。

  4. 在庫ルール: Strategy パターンでルールを実装し、在庫レベル警告や在庫ゼロ警告などのチェックを行います。

  5. 在庫操作メソッド: 在庫エンティティには、入荷(receive)、出荷(ship)、予約(reserve)、調整(adjustStock)などの操作メソッドが実装されています。不変オブジェクトとして、操作のたびに新しいインスタンスを返します。

  6. E2E テスト: Cypress を使って、管理者と利用者の両方のシナリオをカバーするテストを実装しています。テストデータは CSV アップロードで準備します。

次の章では、テスト戦略について解説します。テストピラミッド、TestContainer の活用、受け入れテストの実装方法を学びます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?