feat: 添加低库存预警功能,优化盒子和容器列表界面
This commit is contained in:
32
app.py
32
app.py
@@ -15,6 +15,8 @@ app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
LOW_STOCK_THRESHOLD = 5
|
||||
|
||||
|
||||
BOX_TYPES = {
|
||||
"small_28": {
|
||||
@@ -26,7 +28,7 @@ BOX_TYPES = {
|
||||
"medium_14": {
|
||||
"label": "14格中盒大盒",
|
||||
"default_capacity": 14,
|
||||
"default_desc": "装十四个中等盒子(尺寸不固定)",
|
||||
"default_desc": "14格中盒,内部摆放方向与28格不同",
|
||||
"default_prefix": "B",
|
||||
},
|
||||
"bag": {
|
||||
@@ -185,32 +187,6 @@ def _parse_non_negative_int(raw_value: str, default_value: int = 0) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def ensure_default_boxes() -> None:
|
||||
defaults = [
|
||||
("默认28格大盒", "small_28"),
|
||||
("默认14格中盒", "medium_14"),
|
||||
("默认袋装清单", "bag"),
|
||||
]
|
||||
changed = False
|
||||
for box_name, box_type in defaults:
|
||||
if Box.query.filter_by(name=box_name).first():
|
||||
continue
|
||||
meta = BOX_TYPES[box_type]
|
||||
db.session.add(
|
||||
Box(
|
||||
name=box_name,
|
||||
description=meta["default_desc"],
|
||||
box_type=box_type,
|
||||
slot_capacity=meta["default_capacity"],
|
||||
slot_prefix=meta["default_prefix"],
|
||||
start_number=1,
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def normalize_legacy_data() -> None:
|
||||
db.session.execute(
|
||||
db.text(
|
||||
@@ -347,6 +323,7 @@ def render_box_page(box: Box, error: str = "", notice: str = ""):
|
||||
bag_rows=bag_rows,
|
||||
box_types=BOX_TYPES,
|
||||
slot_range=slot_range_label(box),
|
||||
low_stock_threshold=LOW_STOCK_THRESHOLD,
|
||||
error=error,
|
||||
notice=notice,
|
||||
)
|
||||
@@ -805,7 +782,6 @@ def bootstrap() -> None:
|
||||
db.create_all()
|
||||
ensure_schema()
|
||||
normalize_legacy_data()
|
||||
ensure_default_boxes()
|
||||
|
||||
|
||||
bootstrap()
|
||||
|
||||
@@ -18,6 +18,7 @@ body {
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: radial-gradient(circle at top right, #defff8, var(--bg) 42%);
|
||||
color: var(--text);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -45,6 +46,52 @@ body {
|
||||
padding: 0 16px 28px;
|
||||
}
|
||||
|
||||
.layout-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.catalog-sidebar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.catalog-sidebar h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.catalog-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.catalog-nav a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: #f5fafc;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.catalog-nav a:hover {
|
||||
border-color: #9ccfd1;
|
||||
background: #ecfbf8;
|
||||
}
|
||||
|
||||
.catalog-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-content > h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
@@ -79,6 +126,7 @@ body {
|
||||
|
||||
.group-panel {
|
||||
margin-bottom: 16px;
|
||||
scroll-margin-top: 20px;
|
||||
}
|
||||
|
||||
.group-title-row {
|
||||
@@ -101,13 +149,13 @@ body {
|
||||
|
||||
.new-box-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr 0.7fr 1fr auto auto;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.new-box-form.compact {
|
||||
grid-template-columns: 1.1fr 0.8fr 0.7fr 1fr auto auto;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.new-box-form .suggest-preview {
|
||||
@@ -115,6 +163,10 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-box-form button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.box-card,
|
||||
.panel {
|
||||
background: var(--card);
|
||||
@@ -127,6 +179,7 @@ body {
|
||||
.box-card {
|
||||
width: 300px;
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.box-card h3 {
|
||||
@@ -171,10 +224,14 @@ body {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slot-grid-14 {
|
||||
.slot-grid-28-fixed {
|
||||
grid-template-columns: repeat(7, minmax(90px, 1fr));
|
||||
}
|
||||
|
||||
.slot-grid-14-fixed {
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.slot-grid-bag {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
@@ -197,6 +254,11 @@ body {
|
||||
background: #ecfbf8;
|
||||
}
|
||||
|
||||
.slot.low-stock {
|
||||
border-color: #fda4af;
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
.slot-no {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
@@ -291,6 +353,19 @@ td {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.layout-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.catalog-nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.box-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -301,7 +376,7 @@ td {
|
||||
}
|
||||
|
||||
.slot-grid,
|
||||
.slot-grid-14,
|
||||
.slot-grid-14-fixed,
|
||||
.slot-grid-bag {
|
||||
grid-template-columns: repeat(4, minmax(70px, 1fr));
|
||||
}
|
||||
|
||||
@@ -109,14 +109,20 @@
|
||||
</section>
|
||||
{% else %}
|
||||
<p class="group-desc">容量: {{ box.slot_capacity }} 位 | 编号范围: {{ slot_range }}</p>
|
||||
<section class="slot-grid{% if box.slot_capacity <= 14 %} slot-grid-14{% endif %}{% if box.slot_capacity <= 4 %} slot-grid-bag{% endif %}">
|
||||
<section class="slot-grid{% if box.box_type == 'small_28' %} slot-grid-28-fixed{% endif %}{% if box.box_type == 'medium_14' %} slot-grid-14-fixed{% endif %}{% if box.slot_capacity <= 4 %} slot-grid-bag{% endif %}">
|
||||
{% for item in slots %}
|
||||
<a class="slot {% if item.component %}filled{% endif %}" href="{{ url_for('edit_component', box_id=box.id, slot=item.slot) }}">
|
||||
<a class="slot {% if item.component %}filled{% endif %}{% if item.component and item.component.quantity < low_stock_threshold %} low-stock{% endif %}" href="{{ url_for('edit_component', box_id=box.id, slot=item.slot) }}">
|
||||
{% if box.box_type in ['small_28', 'medium_14'] %}
|
||||
<span class="slot-no">位置 {{ '%02d'|format(item.slot) }}</span>
|
||||
{% else %}
|
||||
<span class="slot-no">{{ item.slot_code }}</span>
|
||||
{% endif %}
|
||||
{% if item.component %}
|
||||
<strong>{{ item.component.part_no }}</strong>
|
||||
<small>{{ item.component.name }}</small>
|
||||
<small>库存: {{ item.component.quantity }} | {% if item.component.is_enabled %}启用{% else %}停用{% endif %}</small>
|
||||
<small>数量: {{ item.component.quantity }}</small>
|
||||
{% if item.component.quantity < low_stock_threshold %}
|
||||
<small>低库存预警</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<small>空位</small>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,74 +13,87 @@
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<h2>容器列表</h2>
|
||||
<div class="layout-shell">
|
||||
<aside class="catalog-sidebar panel">
|
||||
<h2>容器导航</h2>
|
||||
<nav class="catalog-nav">
|
||||
{% for key, meta in box_types.items() %}
|
||||
<a href="#group-{{ key }}">{{ meta.label }} ({{ groups[key]|length }})</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{% for key, meta in box_types.items() %}
|
||||
<section class="group-panel panel" id="group-{{ key }}">
|
||||
<div class="group-title-row">
|
||||
<h3>{{ meta.label }}</h3>
|
||||
<span class="group-desc">{{ meta.default_desc }}</span>
|
||||
</div>
|
||||
<section class="catalog-content">
|
||||
<h2>容器列表</h2>
|
||||
|
||||
<form class="new-box-form" method="post" action="{{ url_for('create_box') }}">
|
||||
<input type="hidden" name="box_type" value="{{ key }}">
|
||||
<input type="text" name="name" placeholder="基础名称(自动拼范围)" required>
|
||||
<input type="text" name="slot_prefix" placeholder="前缀(如A/B/C)">
|
||||
<input type="number" name="start_number" min="0" value="1" placeholder="起始序号">
|
||||
<input type="text" name="description" placeholder="备注(可选)">
|
||||
<button class="btn btn-light suggest-start-btn" type="button" data-box-type="{{ key }}">建议起始号</button>
|
||||
<button class="btn" type="submit">新增盒子</button>
|
||||
<span class="hint suggest-preview"></span>
|
||||
</form>
|
||||
|
||||
<section class="box-list">
|
||||
{% for item in groups[key] %}
|
||||
<article class="box-card">
|
||||
<h4>{{ item.box.name }}</h4>
|
||||
<p>{{ item.box.description or '暂无描述' }}</p>
|
||||
<p>编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p>
|
||||
<p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p>
|
||||
|
||||
<div class="card-actions">
|
||||
<a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入列表</a>
|
||||
<form method="post" action="{{ url_for('delete_box', box_id=item.box.id) }}" onsubmit="return confirm('确认删除盒子及内部全部记录吗?')">
|
||||
<button class="btn btn-danger" type="submit">删除</button>
|
||||
</form>
|
||||
{% for key, meta in box_types.items() %}
|
||||
<section class="group-panel panel" id="group-{{ key }}">
|
||||
<div class="group-title-row">
|
||||
<h3>{{ meta.label }}</h3>
|
||||
<span class="group-desc">{{ meta.default_desc }}</span>
|
||||
</div>
|
||||
|
||||
<details class="box-overview">
|
||||
<summary>概览(已启用序号和名称)</summary>
|
||||
{% if item.overview_rows %}
|
||||
<ul>
|
||||
{% for row in item.overview_rows %}
|
||||
<li>{{ row.slot_code }} - {{ row.name }} ({{ row.part_no }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>暂无启用记录</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
<form class="new-box-form" method="post" action="{{ url_for('create_box') }}">
|
||||
<input type="hidden" name="box_type" value="{{ key }}">
|
||||
<input type="text" name="name" placeholder="基础名称(自动拼范围)" required>
|
||||
<input type="text" name="slot_prefix" placeholder="前缀(如A/B/C)">
|
||||
<input type="number" name="start_number" min="0" value="1" placeholder="起始序号">
|
||||
<input type="text" name="description" placeholder="备注(可选)">
|
||||
<button class="btn btn-light suggest-start-btn" type="button" data-box-type="{{ key }}">建议起始号</button>
|
||||
<button class="btn" type="submit">新增盒子</button>
|
||||
<span class="hint suggest-preview"></span>
|
||||
</form>
|
||||
|
||||
<details class="box-overview">
|
||||
<summary>设置(改名/前缀/起始号)</summary>
|
||||
<p class="hint">输入基础名称后,系统会自动生成: 基础名称 + 编号范围。</p>
|
||||
<form class="new-box-form compact" method="post" action="{{ url_for('update_box', box_id=item.box.id) }}">
|
||||
<input type="text" name="name" value="{{ item.base_name }}" required>
|
||||
<input type="text" name="slot_prefix" value="{{ item.box.slot_prefix }}" required>
|
||||
<input type="number" name="start_number" min="0" value="{{ item.box.start_number }}" required>
|
||||
<input type="text" name="description" value="{{ item.box.description or '' }}">
|
||||
<button class="btn btn-light suggest-start-btn" type="button" data-box-id="{{ item.box.id }}" data-box-type="{{ item.box.box_type }}">建议起始号</button>
|
||||
<button class="btn" type="submit">保存设置</button>
|
||||
<span class="hint suggest-preview"></span>
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
{% else %}
|
||||
<p>当前分类还没有盒子,先新增一个。</p>
|
||||
<section class="box-list">
|
||||
{% for item in groups[key] %}
|
||||
<article class="box-card">
|
||||
<h4>{{ item.box.name }}</h4>
|
||||
<p>{{ item.box.description or '暂无描述' }}</p>
|
||||
<p>编号前缀: {{ item.box.slot_prefix }} | 范围: {{ item.slot_range }}</p>
|
||||
<p>已启用: {{ item.used_count }}/{{ item.box.slot_capacity }}</p>
|
||||
|
||||
<div class="card-actions">
|
||||
<a class="btn" href="{{ url_for('view_box', box_id=item.box.id) }}">进入列表</a>
|
||||
<form method="post" action="{{ url_for('delete_box', box_id=item.box.id) }}" onsubmit="return confirm('确认删除盒子及内部全部记录吗?')">
|
||||
<button class="btn btn-danger" type="submit">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details class="box-overview">
|
||||
<summary>概览(已启用序号和名称)</summary>
|
||||
{% if item.overview_rows %}
|
||||
<ul>
|
||||
{% for row in item.overview_rows %}
|
||||
<li>{{ row.slot_code }} - {{ row.name }} ({{ row.part_no }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>暂无启用记录</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
|
||||
<details class="box-overview">
|
||||
<summary>设置(改名/前缀/起始号)</summary>
|
||||
<p class="hint">输入基础名称后,系统会自动生成: 基础名称 + 编号范围。</p>
|
||||
<form class="new-box-form compact" method="post" action="{{ url_for('update_box', box_id=item.box.id) }}">
|
||||
<input type="text" name="name" value="{{ item.base_name }}" required>
|
||||
<input type="text" name="slot_prefix" value="{{ item.box.slot_prefix }}" required>
|
||||
<input type="number" name="start_number" min="0" value="{{ item.box.start_number }}" required>
|
||||
<input type="text" name="description" value="{{ item.box.description or '' }}">
|
||||
<button class="btn btn-light suggest-start-btn" type="button" data-box-id="{{ item.box.id }}" data-box-type="{{ item.box.box_type }}">建议起始号</button>
|
||||
<button class="btn" type="submit">保存设置</button>
|
||||
<span class="hint suggest-preview"></span>
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
{% else %}
|
||||
<p>当前分类还没有盒子,先新增一个。</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user