feat(malaysia): 재고조사 랙(rack) 박스 단위 입력
낱개/콤보 평면 수량 그리드를 창고 랙 그림(A/B 36칸) 입력으로 대체.
각 칸에 아이템·박스당 입수량·박스 수를 넣고 + 로 한 칸 여러 아이템 추가.
저장 시 SKU별 qty=SUM(입수량×박스수) 집계로 daily_stocktake_line 재생성.
이후 분해/확정 로직은 라인 기준 그대로 동작.
- DB: daily_stocktake_rack 테이블 + grant (멱등)
- store: rack_cell_codes/rack_layout/aggregate_rack 순수함수
- db: list_rack_entries/replace_rack(1 트랜잭션, draft만)
- router: rack 컨텍스트 + POST /stocktakes/{id}/rack/bulk
- UI: 랙 보드 템플릿 + malaysia_rack.js(행 추가/삭제·실시간 소계)
- 테스트 4건 추가(18 전부 통과)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:8px;">
|
||||
<button type="button" id="mys-lang-toggle" class="erp-btn erp-btn-outline">ENG</button>
|
||||
</div>
|
||||
<script src="/static/malaysia.js?v=20260612b"></script>
|
||||
<script src="/static/malaysia.js?v=20260614a"></script>
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% macro rack_row(cell, items, entry=None, disabled=False) %}
|
||||
<div class="rk-row">
|
||||
<input type="hidden" name="rk_cell" value="{{ cell }}">
|
||||
<select name="rk_sku" class="erp-input rk-sku" {% if disabled %}disabled{% endif %}>
|
||||
<option value="">— 선택 —</option>
|
||||
{% for it in items %}
|
||||
<option value="{{ it.item_code }}" {% if entry and entry.sku_code == it.item_code %}selected{% endif %}>{{ it.item_code }} · {{ it.item_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="erp-input rk-upb" type="number" min="1" name="rk_upb" placeholder="입수량"
|
||||
value="{{ entry.units_per_box if entry else '' }}" {% if disabled %}disabled{% endif %} />
|
||||
<span class="rk-x">×</span>
|
||||
<input class="erp-input rk-box" type="number" min="1" name="rk_box" placeholder="박스"
|
||||
value="{{ entry.box_count if entry else '' }}" {% if disabled %}disabled{% endif %} />
|
||||
<span class="rk-sub">{{ (entry.units_per_box * entry.box_count) if entry else 0 }}</span>
|
||||
{% if not disabled %}<button type="button" class="rk-del" title="행 삭제">×</button>{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.mys-compact .erp-table th,
|
||||
.mys-compact .erp-table td { padding-top:4px; padding-bottom:4px; }
|
||||
.mys-compact .erp-input { padding-top:4px; padding-bottom:4px; }
|
||||
.mys-compact td.nm { white-space:nowrap; }
|
||||
.mys-compact th.ccol, .mys-compact td.ccol { white-space:nowrap; width:88px; }
|
||||
.mys-compact .qcol { width:110px; }
|
||||
.mys-compact .qcol .erp-input { width:100px; }
|
||||
/* 랙 보드 */
|
||||
.rack-board { display:flex; gap:24px; flex-wrap:wrap; }
|
||||
.rack-side { flex:1 1 480px; }
|
||||
.rack-side h3 { text-align:center; margin:0 0 8px; }
|
||||
.rack-row { display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; margin-bottom:6px; }
|
||||
.rack-cell {
|
||||
border:1px solid #cbd5e1; border-radius:6px; padding:4px;
|
||||
background:#f8fafc; min-height:84px; display:flex; flex-direction:column;
|
||||
}
|
||||
.rack-cell-head {
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
font-size:11px; font-weight:600; color:#475569; margin-bottom:3px;
|
||||
}
|
||||
.rack-cell-head .rk-cell-total { color:#0f172a; }
|
||||
.rack-cell-head .rk-add {
|
||||
border:1px solid #94a3b8; background:#fff; border-radius:4px;
|
||||
width:18px; height:18px; line-height:1; padding:0; cursor:pointer; font-weight:700;
|
||||
}
|
||||
.rk-rows { display:flex; flex-direction:column; gap:3px; }
|
||||
.rk-row { display:grid; grid-template-columns: 1fr 38px 8px 32px 30px 16px; gap:2px; align-items:center; }
|
||||
.rk-row .rk-sku { font-size:10px; padding:1px 2px; }
|
||||
.rk-row .rk-upb, .rk-row .rk-box { font-size:10px; padding:1px 2px; text-align:right; }
|
||||
.rk-row .rk-x { font-size:10px; color:#94a3b8; text-align:center; }
|
||||
.rk-row .rk-sub { font-size:10px; text-align:right; color:#2563eb; font-weight:600; }
|
||||
.rk-row .rk-del {
|
||||
border:none; background:transparent; color:#dc2626; cursor:pointer;
|
||||
font-size:13px; line-height:1; padding:0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,8 +61,8 @@
|
||||
<a class="erp-btn erp-btn-primary" href="/malaysia/stocktakes?wh={{ stocktake.warehouse_code }}">◀◀ 조사 목록</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/lines/bulk">
|
||||
<div style="display:grid;grid-template-columns:280px 1fr 1fr;gap:16px;align-items:start;">
|
||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/rack/bulk">
|
||||
<div style="display:grid;grid-template-columns:280px 1fr;gap:16px;align-items:start;">
|
||||
|
||||
<!-- 좌: 정보 + 동작 -->
|
||||
<div class="erp-card">
|
||||
@@ -40,6 +82,10 @@
|
||||
<p style="color:#b91c1c;font-weight:600;margin-top:10px;">⚠ BOM 없는 콤보: {{ missing_bom|join(', ') }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="erp-muted" style="margin-top:10px;font-size:12px;">
|
||||
<span class="i18n">랙 칸에 아이템·입수량(박스당 개수)·박스 수를 입력하면 낱개/콤보 수량이 자동 집계됩니다. 한 칸에 여러 아이템은 + 로 추가하세요.</span>
|
||||
</p>
|
||||
|
||||
{% if is_draft %}
|
||||
<div class="erp-page-actions" style="margin-top:12px;">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">입력 저장</button>
|
||||
@@ -47,48 +93,49 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 중: 낱개 -->
|
||||
<!-- 우: 랙 보드 -->
|
||||
<div class="erp-card">
|
||||
<div class="cpg-card-head"><h2>낱개 상품</h2></div>
|
||||
<div class="erp-table-wrap" style="margin-top:8px;">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th class="ccol">Code</th><th>Name</th><th class="qcol">Qty</th></tr></thead>
|
||||
<tbody>
|
||||
{% for it in individual_items %}
|
||||
<tr>
|
||||
<td class="ccol">{{ it.item_code }}</td>
|
||||
<td class="nm" data-en="{{ it.item_name }}" data-ko="{{ ko_names.get(it.item_code, it.item_name) }}">{{ it.item_name }}</td>
|
||||
<td class="qcol"><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}"
|
||||
value="{{ entered.get(it.item_code, '') }}" placeholder="—" {% if not is_draft %}disabled{% endif %} /></td>
|
||||
</tr>
|
||||
<div class="cpg-card-head"><h2>랙 입력 (박스 단위)</h2></div>
|
||||
<div class="rack-board" style="margin-top:10px;">
|
||||
{% for side in rack_layout %}
|
||||
<div class="rack-side">
|
||||
<h3>Side {{ side.side }}</h3>
|
||||
{% for row in side.rows %}
|
||||
<div class="rack-row">
|
||||
{% for col in row.cols %}
|
||||
{% for cell in col %}
|
||||
<div class="rack-cell" data-cell="{{ cell }}">
|
||||
<div class="rack-cell-head">
|
||||
<span>{{ cell }}</span>
|
||||
<span class="rk-cell-total">∑ <b class="rk-cell-total-v">0</b></span>
|
||||
{% if is_draft %}<button type="button" class="rk-add" title="아이템 추가">+</button>{% endif %}
|
||||
</div>
|
||||
<div class="rk-rows">
|
||||
{% set entries = rack_by_cell.get(cell, []) %}
|
||||
{% for e in entries %}
|
||||
{{ rack_row(cell, rack_items, entry=e, disabled=(not is_draft)) }}
|
||||
{% endfor %}
|
||||
{% if is_draft and not entries %}
|
||||
{{ rack_row(cell, rack_items) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우: 콤보 -->
|
||||
<div class="erp-card">
|
||||
<div class="cpg-card-head"><h2>콤보 상품</h2></div>
|
||||
<div class="erp-table-wrap" style="margin-top:8px;">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th class="ccol">Set Code</th><th>Set Name</th><th class="qcol">Qty</th></tr></thead>
|
||||
<tbody>
|
||||
{% for it in set_items %}
|
||||
<tr>
|
||||
<td class="ccol">{{ it.item_code }}</td>
|
||||
<td class="nm" data-en="{{ it.item_name }}" data-ko="{{ ko_names.get(it.item_code, it.item_name) }}">{{ it.item_name }}</td>
|
||||
<td class="qcol"><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}"
|
||||
value="{{ entered.get(it.item_code, '') }}" placeholder="—" {% if not is_draft %}disabled{% endif %} /></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- JS 클론용 빈 행 템플릿(옵션 1벌만 보유) -->
|
||||
<template id="rk-row-tpl">
|
||||
{{ rack_row('', rack_items) }}
|
||||
</template>
|
||||
|
||||
<!-- 확정/취소/삭제 -->
|
||||
<div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
{% if is_draft %}
|
||||
@@ -135,4 +182,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script src="/static/malaysia_rack.js?v=20260614a"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user