feat: 物料增加初始单位,补充物料批量上传计划

- 物料默认单位由 pcs 改为「个」,新建/编辑表单与后端 Create 保持一致
- 单位下拉增加未选择时的默认提示
- init.sql 补充 30 个常用计量单位种子数据
- init.sql 新增 material_import_logs 表,为批量导入提供落库
- 新增物料批量导入实施计划文档,相关测试期望值同步更新
This commit is contained in:
文睿 张 2026-10-10 14:15:49 +08:00
parent 8dd0427adb
commit 616a4a3b1d
8 changed files with 2560 additions and 10 deletions

File diff suppressed because it is too large Load Diff

View File

@ -105,7 +105,7 @@
</el-select>
</el-form-item>
<el-form-item label="单位">
<el-select v-model="form.base_unit_id" style="width: 100%" @change="syncUnit">
<el-select v-model="form.base_unit_id" placeholder="未选择单位时,默认使用“个”" style="width: 100%" @change="syncUnit">
<el-option v-for="unit in units" :key="unit.id" :label="unit.code === unit.name ? unit.code : `${unit.code} - ${unit.name}`" :value="unit.id" />
</el-select>
</el-form-item>

View File

@ -18,7 +18,7 @@ export function createMaterialForm(source = {}, categories = []) {
name: source.name || '',
spec: source.spec || '',
category: source.category || '',
unit: source.unit || 'pcs',
unit: source.unit || '个',
material_image: source.material_image || '',
major_category_id: source.major_category_id ?? major?.id ?? null,
category_id: source.category_id ?? null,
@ -31,6 +31,6 @@ export function toMaterialPayload(source) {
for (const key of materialKeys) {
payload[key] = key.endsWith('_id') ? source[key] ?? null : source[key] || ''
}
if (!payload.unit) payload.unit = 'pcs'
if (!payload.unit) payload.unit = '个'
return payload
}

View File

@ -13,7 +13,7 @@ describe('material master-data form', () => {
name: '',
spec: '',
category: '',
unit: 'pcs',
unit: '个',
material_image: '',
major_category_id: null,
category_id: null,

View File

@ -150,12 +150,12 @@ func TestCreateMaterialUsesHeaderOrganizationInsteadOfBody(t *testing.T) {
mock.ExpectQuery(lockCodeRulePattern).WithArgs(int64(1), int64(12)).
WillReturnRows(materialRow([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
mock.ExpectExec(materialInsertPattern).
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1).
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectCommit()
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(7)).
WillReturnRows(materialRow(materialColumns(),
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1, time.Now(), time.Now()))
mock.ExpectExec(materialLogInsertPattern).
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "operator", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))

View File

@ -74,7 +74,7 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
}
unit := req.Unit
if unit == "" {
unit = "pcs"
unit = "个"
}
var materialImage *string

View File

@ -156,7 +156,7 @@ func expectCodeTaken(mock sqlmock.Sqlmock, code string, taken bool) {
func expectMaterialInsert(mock sqlmock.Sqlmock, code string) {
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
WithArgs(int64(1), code, "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1).
WithArgs(int64(1), code, "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
WillReturnResult(sqlmock.NewResult(7, 1))
}
@ -164,7 +164,7 @@ func expectCreatedMaterial(mock sqlmock.Sqlmock, code string) {
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, code, "轴承", nil, "", nil, 12, nil, "pcs", "active", 1, now, now),
AddRow(7, 1, code, "轴承", nil, "", nil, 12, nil, "个", "active", 1, now, now),
)
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
@ -289,7 +289,7 @@ func TestMaterialServiceReportsDuplicateManuallyEditedCode(t *testing.T) {
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1).
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
mock.ExpectRollback()

View File

@ -18,6 +18,7 @@ CREATE TABLE material_categories (id BIGINT AUTO_INCREMENT PRIMARY KEY, organiza
CREATE TABLE units (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, precision_scale TINYINT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_units_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE materials (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_code VARCHAR(50) NOT NULL, name VARCHAR(200) NOT NULL, material_image VARCHAR(500), spec VARCHAR(200), manufacturer_model VARCHAR(200), material_type_id BIGINT, category_id BIGINT NOT NULL, base_unit_id BIGINT, unit VARCHAR(20), status VARCHAR(20) NOT NULL DEFAULT 'active', version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_materials_org_code(organization_id,material_code), KEY idx_materials_category(category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(material_type_id) REFERENCES material_types(id) ON DELETE RESTRICT, FOREIGN KEY(category_id) REFERENCES material_categories(id) ON DELETE RESTRICT, FOREIGN KEY(base_unit_id) REFERENCES units(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE code_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, category_id BIGINT NOT NULL, prefix VARCHAR(30) NOT NULL, sequence_length TINYINT NOT NULL, next_sequence BIGINT NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_code_rules_org_category(organization_id,category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(category_id) REFERENCES material_categories(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_import_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, operator VARCHAR(100) NOT NULL, file_name VARCHAR(255) NOT NULL, total_rows INT NOT NULL DEFAULT 0, success_rows INT NOT NULL DEFAULT 0, failed_rows INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL, failure_reason VARCHAR(1000) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_import_logs_org_created(organization_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_unit_conversions (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, from_unit_id BIGINT NOT NULL, to_unit_id BIGINT NOT NULL, conversion_rate DECIMAL(20,6) NOT NULL, effective_from DATE NOT NULL, effective_to DATE NULL, KEY idx_material_unit_conversions_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(from_unit_id) REFERENCES units(id), FOREIGN KEY(to_unit_id) REFERENCES units(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, tag_code VARCHAR(100) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tags_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE business_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, legal_name VARCHAR(200) NOT NULL, unified_credit_code VARCHAR(50) UNIQUE, contact_name VARCHAR(100), contact_phone VARCHAR(50), address VARCHAR(500), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -61,6 +62,38 @@ CREATE TABLE IF NOT EXISTS schema_migration_reconciliations (
INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-08-company-master-data-isolation','completed',NOW(),JSON_OBJECT('version','2026-10-08-company-master-data-isolation','source','fresh-init'));
INSERT INTO organizations (id,code,name,status) VALUES (1,'DEFAULT','默认公司组织','active');
-- 常用计量单位归属默认公司;计数、包装使用整数,重量、容量、长度、面积保留 3 位小数。
INSERT INTO units (organization_id,code,name,precision_scale,status) VALUES
(1,'个','个',0,'active'),
(1,'件','件',0,'active'),
(1,'只','只',0,'active'),
(1,'支','支',0,'active'),
(1,'根','根',0,'active'),
(1,'条','条',0,'active'),
(1,'片','片',0,'active'),
(1,'张','张',0,'active'),
(1,'套','套',0,'active'),
(1,'台','台',0,'active'),
(1,'对','对',0,'active'),
(1,'双','双',0,'active'),
(1,'包','包',0,'active'),
(1,'袋','袋',0,'active'),
(1,'盒','盒',0,'active'),
(1,'箱','箱',0,'active'),
(1,'瓶','瓶',0,'active'),
(1,'罐','罐',0,'active'),
(1,'桶','桶',0,'active'),
(1,'卷','卷',0,'active'),
(1,'g','克',3,'active'),
(1,'kg','千克',3,'active'),
(1,'t','吨',3,'active'),
(1,'mL','毫升',3,'active'),
(1,'L','升',3,'active'),
(1,'m³','立方米',3,'active'),
(1,'mm','毫米',3,'active'),
(1,'cm','厘米',3,'active'),
(1,'m','米',3,'active'),
(1,'m²','平方米',3,'active');
INSERT INTO roles (code,name) VALUES ('super_admin','超级管理员'),('warehouse_admin','仓库管理员'),('purchaser','采购人员'),('technician','技术人员'),('production','生产人员'),('sales','销售人员');
INSERT INTO permissions (code,name) VALUES ('master.material.read','查看物料'),('master.material.write','维护物料'),('master.config.write','维护物料基础配置'),('organization.read','查看公司组织'),('organization.write','维护公司组织'),('organization.user.write','维护用户'),('organization.role.write','维护角色与权限'),('partner.supplier.read','查看供应商'),('partner.supplier.write','维护供应商'),('partner.customer.read','查看客户'),('partner.customer.write','维护客户'),('inventory.stock.read','查看库存'),('inventory.warning.write','维护库存预警'),('inventory.inbound.write','维护入库'),('inventory.outbound.write','维护出库');
INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r CROSS JOIN permissions p WHERE r.code='super_admin';