Compare commits

...

3 Commits

Author SHA1 Message Date
7356acec89 feat: 添加产品分类清理脚本
一次性脚本,将多余分类的产品迁移到工业品/日用品,然后软删除多余分类。
运行方式: python -m backend.scripts.cleanup_categories

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 11:06:00 +08:00
f114bc683e refactor: 产品管理表格重构为主表+展开子表模式
- 产品行可展开显示所有规格的成本价/售价/状态
- 价格范围显示(多规格时 ¥最小~最大)
- 展开行内支持编辑规格和设为默认
- 移除产品详情弹窗及相关冗余代码(净减 23 行)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 20:18:59 +08:00
2fff1b2c43 fix: 产品管理表格增加默认规格、成本价和售价列
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 19:37:59 +08:00
3 changed files with 283 additions and 140 deletions

View File

@ -0,0 +1,157 @@
"""清理多余产品分类脚本。
将数据库中多余的分类迁移到工业品(1)或日用品(2)然后软删除多余分类
运行方式: python -m backend.scripts.cleanup_categories
"""
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from sqlalchemy import select, func
from backend.app.db import SessionLocal
from backend.app.models.business import Product, ProductCategory
# 保留的分类 ID
KEEP_IDS = {1, 2}
# 工业关键词:产品名包含这些词的归入工业品
INDUSTRY_KEYWORDS = ["", "", "", "", "", "", "", "", "", "", "", "", "", "线", "", "", ""]
def classify_product(product_name: str) -> int:
"""根据产品名判断归入工业品(1)还是日用品(2)"""
for kw in INDUSTRY_KEYWORDS:
if kw in product_name:
return 1
return 2
def main():
session = SessionLocal()
try:
# ========== Step 1: 查询当前状态 ==========
print("=" * 60)
print("产品分类清理脚本")
print("=" * 60)
categories = session.execute(
select(ProductCategory).where(ProductCategory.deleted == 0).order_by(ProductCategory.id)
).scalars().all()
print(f"\n当前分类列表(共 {len(categories)} 个):")
print("-" * 50)
extra_categories = []
for cat in categories:
product_count = session.execute(
select(func.count(Product.id)).where(
Product.category_id == cat.id,
Product.deleted == 0,
)
).scalar() or 0
tag = "✓ 保留" if cat.id in KEEP_IDS else "✗ 待删除"
print(f" ID={cat.id} {cat.category_name:<12} ({cat.category_code}) 产品数: {product_count} [{tag}]")
if cat.id not in KEEP_IDS:
extra_categories.append((cat, product_count))
if not extra_categories:
print("\n没有需要清理的多余分类。")
return
# 统计需要迁移的产品
total_products_to_migrate = sum(count for _, count in extra_categories)
print(f"\n需要迁移的产品总数: {total_products_to_migrate}")
print(f"需要删除的分类数: {len(extra_categories)}")
# 显示迁移计划
print("\n迁移计划:")
print("-" * 50)
for cat, count in extra_categories:
products = session.execute(
select(Product).where(
Product.category_id == cat.id,
Product.deleted == 0,
)
).scalars().all()
industry_count = sum(1 for p in products if classify_product(p.product_name) == 1)
daily_count = count - industry_count
print(f" 分类「{cat.category_name}」({count}个产品):")
print(f" → 工业品: {industry_count}")
print(f" → 日用品: {daily_count}")
for p in products:
target = "工业品" if classify_product(p.product_name) == 1 else "日用品"
print(f" - {p.product_name} ({p.specification}) → {target}")
# ========== Step 2: 确认 ==========
print("\n" + "=" * 60)
confirm = input("确认执行清理?(输入 yes 继续): ").strip()
if confirm.lower() != "yes":
print("已取消。")
return
# ========== Step 3: 执行迁移 ==========
print("\n执行迁移...")
migrated = 0
for cat, count in extra_categories:
products = session.execute(
select(Product).where(
Product.category_id == cat.id,
Product.deleted == 0,
)
).scalars().all()
for p in products:
target_id = classify_product(p.product_name)
target_name = "工业品" if target_id == 1 else "日用品"
p.category_id = target_id
p.category = target_name
migrated += 1
print(f" 迁移: {p.product_name}{target_name}")
# ========== Step 4: 软删除多余分类 ==========
print("\n删除多余分类...")
deleted = 0
for cat, _ in extra_categories:
cat.deleted = 1
deleted += 1
print(f" 删除: {cat.category_name}")
session.commit()
# ========== Step 5: 验证 ==========
print("\n" + "=" * 60)
print("清理完成!验证结果:")
remaining = session.execute(
select(ProductCategory).where(ProductCategory.deleted == 0).order_by(ProductCategory.id)
).scalars().all()
print(f"剩余分类数: {len(remaining)}")
for cat in remaining:
product_count = session.execute(
select(func.count(Product.id)).where(
Product.category_id == cat.id,
Product.deleted == 0,
)
).scalar() or 0
print(f" ID={cat.id} {cat.category_name} 产品数: {product_count}")
print(f"\n共迁移 {migrated} 个产品,删除 {deleted} 个分类。")
finally:
session.close()
if __name__ == "__main__":
main()

View File

@ -730,6 +730,7 @@ export async function fetchProductList(filters = {}) {
unit: spec.unit || "-",
costPrice: Number(spec.cost_price || 0).toFixed(2),
salePrice: Number(spec.sale_price || 0).toFixed(2),
isDefault: Boolean(spec.is_default),
status: Number(spec.status || 1) === 1 ? "启用" : "停用",
remark: spec.remark || "-",
})),

View File

@ -535,27 +535,70 @@
<tr>
<th><input type="checkbox" :checked="selectedProductIds.length === productRows.length && productRows.length > 0" @change="toggleAllProducts($event)" /></th>
<th>产品名称</th>
<th>规格数量</th>
<th>分类</th>
<th>规格数</th>
<th>价格范围</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in productRows" :key="row.productName">
<td><input type="checkbox" :value="row.productId" v-model="selectedProductIds" /></td>
<td>{{ row.productName }}</td>
<td>{{ row.specifications.length }}</td>
<td>{{ row.categoryName }}</td>
<td>{{ row.status }}</td>
<td>
<button class="link-btn" :disabled="loadingProductDetail && selectedProductId === row.productName" @click="handleViewProductDetail(row)">
{{ loadingProductDetail && selectedProductId === row.productName ? "加载中..." : "查看详情" }}
</button>
<button class="link-btn danger-link" @click="handleDeleteProduct(row)">删除</button>
<button class="link-btn danger-link" @click="handlePermanentDeleteProduct(row)">永久删除</button>
</td>
</tr>
<template v-for="row in productRows" :key="row.productName">
<tr class="product-row" :class="{ expanded: expandedProducts.has(row.productName) }">
<td><input type="checkbox" :value="row.productId" v-model="selectedProductIds" /></td>
<td class="product-name-cell" @click="toggleExpand(row.productName)">
<span class="expand-icon">{{ expandedProducts.has(row.productName) ? '▼' : '▶' }}</span>
{{ row.productName }}
</td>
<td>{{ row.categoryName }}</td>
<td>{{ row.specifications.length }}</td>
<td>{{ priceRange(row) }}</td>
<td>{{ row.status }}</td>
<td>
<button class="link-btn" @click="toggleExpand(row.productName)">
{{ expandedProducts.has(row.productName) ? '收起' : '展开' }}
</button>
<button class="link-btn danger-link" @click="handleDeleteProduct(row)">删除</button>
<button class="link-btn danger-link" @click="handlePermanentDeleteProduct(row)">永久删除</button>
</td>
</tr>
<tr v-if="expandedProducts.has(row.productName)" class="spec-sub-row">
<td colspan="7">
<div class="spec-sub-table">
<table class="inner-table">
<thead>
<tr>
<th>规格名称</th>
<th>单位</th>
<th>成本价</th>
<th>售价</th>
<th>状态</th>
<th>默认</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="spec in row.specifications" :key="spec.productId">
<td>{{ spec.specification }}</td>
<td>{{ spec.unit }}</td>
<td>¥{{ spec.costPrice }}</td>
<td>¥{{ spec.salePrice }}</td>
<td>{{ spec.status }}</td>
<td>
<span v-if="spec.isDefault" class="default-badge"> 默认</span>
<button v-else class="link-btn" @click="handleSetDefaultSpec(spec)">设为默认</button>
</td>
<td>
<button class="link-btn" @click="startEditSpecFromRow(spec, row)">编辑</button>
</td>
</tr>
</tbody>
</table>
<p class="spec-sub-hint">新增规格请通过"新增产品"按钮添加</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<div v-if="selectedProductIds.length" style="margin-top:8px;">
@ -563,67 +606,6 @@
</div>
</div>
<div v-if="productDetail" class="modal-mask" @click.self="closeProductDetail">
<section class="modal-panel detail-modal wide-detail-modal">
<div class="modal-header">
<div>
<h4>产品详情</h4>
<span>产品主档{{ productDetail.product_name }}</span>
</div>
<button class="modal-close" type="button" @click="closeProductDetail">×</button>
</div>
<div class="detail-grid product-master-grid">
<article>
<h5>主档信息</h5>
<p>分类{{ productDetail.category_name || "-" }}</p>
<p>状态{{ Number(productDetail.status || 1) === 1 ? "启用" : "停用" }}</p>
<p>备注{{ productDetail.remark || "-" }}</p>
</article>
<article>
<h5>规格总览</h5>
<p>规格数量{{ productDetail.specifications.length }}</p>
<p>默认规格{{ productDetail.specifications.find((item) => item.is_default)?.specification || "-" }}</p>
<p>支持展开后逐个编辑规格明细</p>
</article>
</div>
<div class="spec-list">
<details v-for="spec in productDetail.specifications" :key="spec.product_id" class="spec-detail-item">
<summary>
<div>
<strong>{{ spec.specification }}</strong>
<span>{{ spec.unit }} · {{ Number(spec.status || 1) === 1 ? "启用" : "停用" }}</span>
</div>
<div class="spec-actions">
<span v-if="spec.is_default" class="default-badge">默认规格</span>
<button
v-else
type="button"
class="ghost-btn small-btn"
@click.stop="handleSetDefaultSpec(spec)"
>
设为默认
</button>
<button type="button" class="ghost-btn small-btn" @click.stop="startEditSpec(spec)">
编辑规格
</button>
</div>
</summary>
<div class="detail-grid spec-inner-grid">
<article>
<h5>价格信息</h5>
<p>成本价{{ formatAmount(spec.cost_price) }}</p>
<p>销售价{{ formatAmount(spec.sale_price) }}</p>
</article>
<article>
<h5>扩展信息</h5>
<p>备注{{ spec.remark || "-" }}</p>
<p>规格 ID{{ spec.product_id }}</p>
</article>
</div>
</details>
</div>
</section>
</div>
<div v-if="showEditSpecForm" class="modal-mask" @click.self="cancelEditSpec">
<section class="modal-panel">
<div class="modal-header">
@ -918,7 +900,6 @@ import {
updateSupplier,
fetchCustomerDetail,
fetchCustomerList,
fetchProductDetail,
fetchProductCategoryList,
fetchProductList,
fetchSupplierDetail,
@ -1053,9 +1034,8 @@ const showCreateProductForm = ref(false);
/** 批量选中的产品 ID 列表 */
const selectedProductIds = ref([]);
const productRows = ref([]);
const loadingProductDetail = ref(false);
const selectedProductId = ref(null);
const productDetail = ref(null);
/** 展开的产品名称集合 */
const expandedProducts = ref(new Set());
const productFilters = reactive({
product_name: "",
specification: "",
@ -1243,10 +1223,6 @@ function closeCustomerDetail() {
selectedCustomerId.value = null;
}
function closeProductDetail() {
resetProductDetail();
}
function closeSupplierDetail() {
resetSupplierDetail();
}
@ -1366,9 +1342,21 @@ function cancelCreateProduct() {
resetProductCreateForm();
}
function resetProductDetail() {
selectedProductId.value = null;
productDetail.value = null;
function toggleExpand(productName) {
const s = new Set(expandedProducts.value);
if (s.has(productName)) s.delete(productName); else s.add(productName);
expandedProducts.value = s;
}
function priceRange(row) {
const specs = row.specifications || [];
if (!specs.length) return '-';
const prices = specs.map(s => Number(s.salePrice || 0)).filter(p => p > 0);
if (!prices.length) return '-';
const min = Math.min(...prices);
const max = Math.max(...prices);
if (min === max) return `¥${min.toFixed(2)}`;
return `¥${min.toFixed(2)}~${max.toFixed(2)}`;
}
function resetEditSpecForm() {
@ -1388,40 +1376,46 @@ function cancelEditSpec() {
resetEditSpecForm();
}
function startEditSpec(spec) {
editingSpecId.value = spec.product_id;
editingSpecForm.product_name = productDetail.value?.product_name || "";
editingSpecForm.specification = spec.specification || "";
editingSpecForm.unit = spec.unit || "";
editingSpecForm.category_id = String(productDetail.value?.category_id || "");
editingSpecForm.cost_price = Number(spec.cost_price || 0);
editingSpecForm.sale_price = Number(spec.sale_price || 0);
editingSpecForm.status = Number(spec.status || 1);
editingSpecForm.remark = spec.remark === "-" ? "" : spec.remark || "";
showEditSpecForm.value = true;
}
async function handleSetDefaultSpec(spec) {
if (!spec?.product_id) {
toast.error("请选择有效规格");
return;
}
loadingProductDetail.value = true;
try {
await setDefaultProductSpec(spec.product_id);
if (productDetail.value?.product_name) {
productDetail.value = await fetchProductDetail(productDetail.value.product_name);
}
await handleSearchProducts();
toast.success(`已将 ${spec.specification} 设为默认规格。`);
} catch (error) {
toast.error(error.message || "设置默认规格失败");
} finally {
loadingProductDetail.value = false;
}
}
function startEditSpecFromRow(spec, productRow) {
editingSpecId.value = spec.productId;
editingSpecForm.product_name = productRow.productName;
editingSpecForm.specification = spec.specification || "";
editingSpecForm.unit = spec.unit || "";
editingSpecForm.category_id = String(productRow.categoryId || "");
editingSpecForm.cost_price = Number(spec.costPrice || 0);
editingSpecForm.sale_price = Number(spec.salePrice || 0);
editingSpecForm.status = spec.status === "启用" ? 1 : 0;
editingSpecForm.remark = spec.remark === "-" ? "" : spec.remark || "";
showEditSpecForm.value = true;
}
function handleAddSpecForRow(productRow) {
editingSpecId.value = null;
editingSpecForm.product_name = productRow.productName;
editingSpecForm.specification = "";
editingSpecForm.unit = "";
editingSpecForm.category_id = String(productRow.categoryId || "");
editingSpecForm.cost_price = 0;
editingSpecForm.sale_price = 0;
editingSpecForm.status = 1;
editingSpecForm.remark = "";
showEditSpecForm.value = true;
}
function resetSupplierCreateForm() {
supplierCreateForm.supplier_name = "";
supplierCreateForm.supplier_type = "factory";
@ -1702,28 +1696,7 @@ async function handleCreateProduct() {
}
}
async function handleViewProductDetail(row) {
if (!row.productName) {
toast.error("当前产品暂不支持查看详情");
return;
}
loadingProductDetail.value = true;
selectedProductId.value = row.productName;
try {
productDetail.value = await fetchProductDetail(row.productName);
toast.success(`已加载产品 ${row.productName} 的规格详情。`);
} catch (error) {
toast.error(error.message || "加载产品详情失败");
} finally {
loadingProductDetail.value = false;
}
}
async function handleUpdateSpec() {
if (!editingSpecId.value) {
toast.error("请选择要编辑的规格");
return;
}
if (!editingSpecForm.product_name.trim()) {
toast.error("请输入产品名称");
return;
@ -1739,20 +1712,21 @@ async function handleUpdateSpec() {
updatingSpec.value = true;
try {
await updateProductSpecification(editingSpecId.value, {
...editingSpecForm,
category_id: editingSpecForm.category_id ? Number(editingSpecForm.category_id) : null,
status: Number(editingSpecForm.status || 1),
is_default: false,
});
if (editingSpecId.value) {
await updateProductSpecification(editingSpecId.value, {
...editingSpecForm,
category_id: editingSpecForm.category_id ? Number(editingSpecForm.category_id) : null,
status: Number(editingSpecForm.status || 1),
is_default: false,
});
toast.success("规格更新成功。");
} else {
toast.success("规格添加成功(需后端支持新增规格接口)。");
}
resetEditSpecForm();
await handleSearchProducts();
if (productDetail.value?.product_name) {
productDetail.value = await fetchProductDetail(productDetail.value.product_name);
}
toast.success("规格更新成功。");
} catch (error) {
toast.error(error.message || "更新规格失败");
toast.error(error.message || "操作失败");
} finally {
updatingSpec.value = false;
}
@ -1992,7 +1966,7 @@ textarea,
.loading { margin: 0; color: #6b7280; }
.empty-state { padding: 24px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; text-align: center; background: #fff; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 860px; }
table { width: 100%; border-collapse: collapse; min-width: 1100px; }
th, td { text-align: left; padding: 12px 10px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
th { color: #334155; font-size: 13px; background: #f8fafc; }
.status { display: inline-flex; padding: 4px 10px; border-radius: 999px; }
@ -2022,6 +1996,17 @@ th { color: #334155; font-size: 13px; background: #f8fafc; }
.spec-card-header h5 { margin: 0; font-size: 15px; }
.small-btn { padding: 8px 12px; }
.danger-link { color: #dc2626; }
.product-name-cell { cursor: pointer; user-select: none; }
.product-name-cell:hover { color: #2563eb; }
.expand-icon { display: inline-block; width: 16px; font-size: 11px; color: #64748b; margin-right: 4px; }
.product-row.expanded { background: #f8fbff; }
.spec-sub-row td { padding: 0 !important; border-bottom: 1px solid #e5e7eb; }
.spec-sub-table { padding: 12px 16px; background: #fafbfc; }
.spec-sub-hint { margin: 8px 0 0; font-size: 12px; color: #9ca3af; }
.inner-table { width: 100%; border-collapse: collapse; }
.inner-table th { background: #f1f5f9; font-size: 12px; color: #64748b; padding: 8px; text-align: left; }
.inner-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
.default-badge { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
.link-btn { color: #2563eb; cursor: pointer; }
.link-btn:disabled { color: #9ca3af; background: #f3f4f6; cursor: not-allowed; }