+
@@ -21,7 +21,12 @@
>
-
+
+
+ {{ label }}
+
+
+
{{ CATEGORY_LABELS[category] || category }}
@@ -32,7 +37,7 @@
-
+
编辑
@@ -44,9 +49,10 @@
客户
供应商
+ 制造商
-
+
{{ label }}
@@ -55,7 +61,7 @@
-
+
@@ -91,6 +97,8 @@ const { runWithSaving } = useSavingGuard()
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
const statusField = computed(() => `${props.role}_status`)
const categoriesField = computed(() => `${props.role}_categories`)
+const hasCooperationFields = computed(() => props.role !== 'manufacturer')
+const ROLE_LABELS = { customer: '客户', supplier: '供应商', manufacturer: '制造商' }
const CATEGORY_LABELS = { material: '物料级(生产)', finished_product: '成品级(经营买卖)' }
const categoryFilter = ref('')
// 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。
@@ -99,7 +107,7 @@ const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘
function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue }
const form = reactive(emptyForm())
-function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false, supplier_categories: [], customer_categories: [] } }
+function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_manufacturer: false, is_customer: false, supplier_categories: [], customer_categories: [] } }
async function load(currentPage = pagination.currentPage) {
loading.value = true
try {
@@ -126,7 +134,7 @@ function openCreate() {
}
function openEdit(row) {
editingId.value = row.id
- businessRoles.value = ['customer', 'supplier'].filter(role => row[`is_${role}`])
+ businessRoles.value = Object.keys(ROLE_LABELS).filter(role => row[`is_${role}`])
replaceAllKeys(form, { ...emptyForm(), ...row, status: row[statusField.value] || 'active',
supplier_categories: [...(row.supplier_categories || [])], customer_categories: [...(row.customer_categories || [])] })
dialogVisible.value = true
@@ -135,8 +143,9 @@ function payload() {
return {
...form,
is_supplier: businessRoles.value.includes('supplier'),
+ is_manufacturer: businessRoles.value.includes('manufacturer'),
is_customer: businessRoles.value.includes('customer'),
- [`${props.role}_status`]: form.status
+ ...(hasCooperationFields.value ? { [statusField.value]: form.status } : {})
}
}
async function save() {
diff --git a/internal/handler/api.go b/internal/handler/api.go
index 6a2eabe1..4c38abac 100644
--- a/internal/handler/api.go
+++ b/internal/handler/api.go
@@ -88,6 +88,8 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
materials.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
{
materials.GET("", h.ListMaterials)
+ materials.GET("/partners", h.ListMaterialPartners)
+ materials.POST("/duplicate-check", h.CheckMaterialDuplicates)
materials.GET("/:id", h.GetMaterial)
materials.POST("", h.CreateMaterial)
materials.PUT("/:id", h.UpdateMaterial)
@@ -313,6 +315,60 @@ func (h *Handler) GetMaterial(c *gin.Context) {
response.Success(c, material)
}
+func (h *Handler) CheckMaterialDuplicates(c *gin.Context) {
+ var req model.MaterialDuplicateCheckRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ orgID, ok := organizationID(c)
+ if !ok {
+ return
+ }
+ items, err := h.materialSvc.FindExactDuplicates(orgID, &req)
+ if err != nil {
+ if errors.Is(err, service.ErrInvalidMaterialSupplier) || errors.Is(err, service.ErrInvalidMaterialManufacturer) {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ response.InternalError(c, err.Error())
+ return
+ }
+ response.Success(c, items)
+}
+
+func (h *Handler) ListMaterialPartners(c *gin.Context) {
+ orgID, ok := organizationID(c)
+ if !ok {
+ return
+ }
+ role := c.Query("role")
+ if role != "supplier" && role != "manufacturer" {
+ response.BadRequest(c, "role must be supplier or manufacturer")
+ return
+ }
+ limit, offset := 100, 0
+ if parsed, err := strconv.Atoi(c.Query("limit")); err == nil && parsed > 0 {
+ limit = parsed
+ if limit > 100 {
+ limit = 100
+ }
+ }
+ if parsed, err := strconv.Atoi(c.Query("offset")); err == nil && parsed >= 0 {
+ offset = parsed
+ }
+ partners, total, err := h.partnerSvc.List(orgID, role, limit, offset, "")
+ if err != nil {
+ response.InternalError(c, err.Error())
+ return
+ }
+ options := make([]model.MaterialPartnerOption, 0, len(partners))
+ for _, partner := range partners {
+ options = append(options, model.MaterialPartnerOption{ID: partner.ID, PartnerCode: partner.PartnerCode, LegalName: partner.LegalName})
+ }
+ response.PaginatedSuccess(c, options, limit, offset, total)
+}
+
func (h *Handler) CreateMaterial(c *gin.Context) {
var req model.CreateMaterialRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -332,7 +388,7 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
material, err := h.materialSvc.Create(&req, operator)
if err != nil {
- if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
+ if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrInvalidMaterialSupplier) || errors.Is(err, service.ErrInvalidMaterialManufacturer) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) || errors.Is(err, service.ErrMaterialDuplicate) {
response.BadRequest(c, err.Error())
return
}
@@ -371,7 +427,7 @@ func (h *Handler) UpdateMaterial(c *gin.Context) {
material, err := h.materialSvc.Update(orgID, id, &req, operator)
if err != nil {
- if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
+ if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrInvalidMaterialSupplier) || errors.Is(err, service.ErrInvalidMaterialManufacturer) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
response.BadRequest(c, err.Error())
return
}
diff --git a/internal/handler/material_organization_test.go b/internal/handler/material_organization_test.go
index d8581644..104f3ca6 100644
--- a/internal/handler/material_organization_test.go
+++ b/internal/handler/material_organization_test.go
@@ -22,9 +22,9 @@ import (
const (
// 以下是与 repository/service 里 SQL 字面量对得上的稳定子串(sqlmock 用正则匹配查询)。
- materialListPattern = `WHERE organization_id = \? AND status = \? ORDER BY id DESC LIMIT \? OFFSET \?`
- materialCountPattern = `SELECT COUNT\(\*\) FROM materials WHERE organization_id = \? AND status = \?`
- materialGetByIDPattern = `FROM materials WHERE organization_id = \? AND id = \?`
+ materialListPattern = `WHERE m.organization_id = \? AND m.status = \? ORDER BY m.id DESC LIMIT \? OFFSET \?`
+ materialCountPattern = `SELECT COUNT\(\*\) FROM materials WHERE organization_id = \? AND materials.status = \?`
+ materialGetByIDPattern = `WHERE m.organization_id = \? AND m.id = \?`
materialInsertPattern = `INSERT INTO materials \(organization_id, material_code`
materialLogInsertPattern = `INSERT INTO material_logs`
lockCodeRulePattern = `FROM code_rules WHERE organization_id = \? AND category_id = \?`
@@ -45,7 +45,7 @@ func materialRow(columns []string, values ...driver.Value) *sqlmock.Rows {
func materialColumns() []string {
return []string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model",
- "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}
+ "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}
}
// expectMaterialOrgAccess 让 OrganizationMiddleware 的 CanAccess 放行指定公司。
@@ -94,7 +94,7 @@ func TestMaterialListScopesToRequestedOrganization(t *testing.T) {
WillReturnRows(materialRow([]string{"count"}, int64(1)))
mock.ExpectQuery(materialListPattern).WithArgs(tc.orgID, "active", 20, 0).
WillReturnRows(materialRow(materialColumns(),
- int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
+ int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, nil, nil, "", "", int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
w := newMaterialRequest(t, "GET", "/api/materials", "", tc.orgID)
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.wantCode) {
@@ -150,17 +150,17 @@ 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, "个", "active", 1).
+ WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, 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, "个", "active", 1, time.Now(), time.Now()))
+ int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, 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))
- body := `{"organization_id":2,"material_code":"HDR-001","name":"轴承","category_id":12}`
+ body := `{"organization_id":2,"material_code":"HDR-001","name":"轴承","category_id":12,"allow_duplicate":true}`
w := newMaterialRequest(t, "POST", "/api/materials", body, 1)
if w.Code != 201 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
diff --git a/internal/handler/order_organization_test.go b/internal/handler/order_organization_test.go
index 50013d7b..44eeafd2 100644
--- a/internal/handler/order_organization_test.go
+++ b/internal/handler/order_organization_test.go
@@ -54,7 +54,7 @@ func emptyWarehouseSummary() *sqlmock.Rows {
func activeMaterialRows(id int64) *sqlmock.Rows {
now := time.Now()
return sqlmock.NewRows(materialColumns()).
- AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, 1, 1, "个", "active", 1, now, now)
+ AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, nil, nil, "", "", 1, 1, "个", "active", 1, now, now)
}
// TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。
diff --git a/internal/model/material_import.go b/internal/model/material_import.go
index a9bed3d7..b261ecec 100644
--- a/internal/model/material_import.go
+++ b/internal/model/material_import.go
@@ -5,22 +5,26 @@ import "time"
// MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1
// 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。
type MaterialImportRow struct {
- Row int
- Name string
- Spec string
- Unit string
- MajorName string
- MinorName string
+ Row int
+ Name string
+ Spec string
+ Unit string
+ Supplier string
+ Manufacturer string
+ MajorName string
+ MinorName string
}
// Data 返回该行的原始内容,用于错误明细里的"原始数据"。
func (r MaterialImportRow) Data() map[string]string {
return map[string]string{
- "name": r.Name,
- "spec": r.Spec,
- "unit": r.Unit,
- "major": r.MajorName,
- "minor": r.MinorName,
+ "name": r.Name,
+ "spec": r.Spec,
+ "unit": r.Unit,
+ "supplier": r.Supplier,
+ "manufacturer": r.Manufacturer,
+ "major": r.MajorName,
+ "minor": r.MinorName,
}
}
diff --git a/internal/model/model.go b/internal/model/model.go
index 16edc779..47141c87 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -17,6 +17,10 @@ type Material struct {
MaterialImage *string `json:"material_image,omitempty"`
Spec string `json:"spec,omitempty"`
ManufacturerModel *string `json:"manufacturer_model,omitempty"`
+ SupplierID *int64 `json:"supplier_id,omitempty"`
+ ManufacturerID *int64 `json:"manufacturer_id,omitempty"`
+ SupplierName string `json:"supplier_name,omitempty"`
+ ManufacturerName string `json:"manufacturer_name,omitempty"`
CategoryID *int64 `json:"category_id,omitempty"`
BaseUnitID *int64 `json:"base_unit_id,omitempty"`
Unit string `json:"unit,omitempty"`
@@ -230,6 +234,9 @@ type CreateMaterialRequest struct {
MaterialImage *string `json:"material_image"`
Spec string `json:"spec"`
ManufacturerModel *string `json:"manufacturer_model"`
+ SupplierID *int64 `json:"supplier_id"`
+ ManufacturerID *int64 `json:"manufacturer_id"`
+ AllowDuplicate bool `json:"allow_duplicate"`
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"`
@@ -242,12 +249,30 @@ type UpdateMaterialRequest struct {
MaterialImage *string `json:"material_image"`
Spec string `json:"spec"`
ManufacturerModel *string `json:"manufacturer_model"`
+ SupplierID *int64 `json:"supplier_id"`
+ ManufacturerID *int64 `json:"manufacturer_id"`
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
BaseUnitID *int64 `json:"base_unit_id"`
Unit string `json:"unit"`
Status string `json:"status"`
}
+type MaterialDuplicateCheckRequest struct {
+ Name string `json:"name" binding:"required"`
+ Spec string `json:"spec"`
+ Unit string `json:"unit"`
+ BaseUnitID *int64 `json:"base_unit_id"`
+ SupplierID *int64 `json:"supplier_id"`
+ ManufacturerID *int64 `json:"manufacturer_id"`
+ ExcludeID int64 `json:"exclude_id"`
+}
+
+type MaterialPartnerOption struct {
+ ID int64 `json:"id"`
+ PartnerCode string `json:"partner_code"`
+ LegalName string `json:"legal_name"`
+}
+
// CreateInboundRequest represents the request to create an inbound order
type CreateInboundRequest struct {
Handler string `json:"handler" binding:"required"`
diff --git a/internal/model/partner.go b/internal/model/partner.go
index 1b6f408b..49b35124 100644
--- a/internal/model/partner.go
+++ b/internal/model/partner.go
@@ -11,6 +11,7 @@ type BusinessPartner struct {
ContactPhone string `json:"contact_phone"`
Address string `json:"address"`
IsSupplier bool `json:"is_supplier"`
+ IsManufacturer bool `json:"is_manufacturer"`
IsCustomer bool `json:"is_customer"`
SupplierStatus string `json:"supplier_status"`
CustomerStatus string `json:"customer_status"`
diff --git a/internal/repository/material.go b/internal/repository/material.go
index 41577c3c..1f8f3975 100644
--- a/internal/repository/material.go
+++ b/internal/repository/material.go
@@ -55,6 +55,17 @@ func (r *MaterialRepo) IsActiveUnit(unitID, organizationID int64) (bool, error)
return exists, err
}
+// IsPartnerForRole 校验伙伴属于当前公司并具有物料字段要求的身份。
+func (r *MaterialRepo) IsPartnerForRole(partnerID, organizationID int64, role string) (bool, error) {
+ field := "is_supplier"
+ if role == "manufacturer" {
+ field = "is_manufacturer"
+ }
+ var exists bool
+ err := database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM organization_partners WHERE id = ? AND organization_id = ? AND `+field+` = TRUE)`, partnerID, organizationID).Scan(&exists)
+ return exists, err
+}
+
// sqlExecer 让插入语句既能直接走连接池,也能跑在事务里。
type sqlExecer interface {
Exec(query string, args ...interface{}) (sql.Result, error)
@@ -63,9 +74,9 @@ type sqlExecer interface {
// insertMaterial 写入物料并回填自增主键,唯一键冲突统一翻译成
// ErrDuplicateMaterialCode。
func insertMaterial(execer sqlExecer, m *model.Material) error {
- query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
result, err := execer.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
- m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
+ m.ManufacturerModel, m.SupplierID, m.ManufacturerID, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
if err != nil {
if strings.Contains(err.Error(), "Duplicate entry") {
return ErrDuplicateMaterialCode
@@ -165,13 +176,20 @@ func (r *MaterialRepo) CreateWithCodeRule(m *model.Material, submittedCode strin
// GetByID 读取本公司物料。organization_id 与 id 同时作为条件,跨公司记录读不到
// 会返回 sql.ErrNoRows,由上层映射成 404。
func (r *MaterialRepo) GetByID(organizationID, id int64, m *model.Material) error {
- query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
- category_id, base_unit_id, unit, status, version, created_at, updated_at
- FROM materials WHERE organization_id = ? AND id = ?`
+ query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
+ m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
+ m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
+ FROM materials m
+ LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
+ LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
+ LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
+ LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
+ WHERE m.organization_id = ? AND m.id = ?`
var materialImage sql.NullString
var manufacturerModel sql.NullString
+ var supplierID, manufacturerID sql.NullInt64
err := database.DB.QueryRow(query, organizationID, id).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage,
- &m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID,
+ &m.Spec, &manufacturerModel, &supplierID, &manufacturerID, &m.SupplierName, &m.ManufacturerName, &m.CategoryID, &m.BaseUnitID,
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return err
@@ -182,27 +200,24 @@ func (r *MaterialRepo) GetByID(organizationID, id int64, m *model.Material) erro
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
+ if supplierID.Valid {
+ m.SupplierID = &supplierID.Int64
+ }
+ if manufacturerID.Valid {
+ m.ManufacturerID = &manufacturerID.Int64
+ }
return nil
}
func (r *MaterialRepo) GetByMaterialCode(organizationID int64, code string) (*model.Material, error) {
- m := &model.Material{}
- query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
- category_id, base_unit_id, unit, status, version, created_at, updated_at
- FROM materials WHERE organization_id = ? AND material_code = ?`
- var materialImage sql.NullString
- var manufacturerModel sql.NullString
- err := database.DB.QueryRow(query, organizationID, code).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage,
- &m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID,
- &m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
+ var id int64
+ err := database.DB.QueryRow(`SELECT id FROM materials WHERE organization_id = ? AND material_code = ?`, organizationID, code).Scan(&id)
if err != nil {
return nil, err
}
- if materialImage.Valid {
- m.MaterialImage = &materialImage.String
- }
- if manufacturerModel.Valid {
- m.ManufacturerModel = &manufacturerModel.String
+ m := &model.Material{}
+ if err := r.GetByID(organizationID, id, m); err != nil {
+ return nil, err
}
return m, nil
}
@@ -211,12 +226,12 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
countQuery := `SELECT COUNT(*) FROM materials WHERE organization_id = ?`
countArgs := []interface{}{organizationID}
if status != "all" {
- countQuery += ` AND status = ?`
+ countQuery += ` AND materials.status = ?`
countArgs = append(countArgs, status)
}
if search != "" {
- countQuery += ` AND (material_code LIKE ? OR name LIKE ?)`
+ countQuery += ` AND (materials.material_code LIKE ? OR materials.name LIKE ?)`
searchPattern := "%" + search + "%"
countArgs = append(countArgs, searchPattern, searchPattern)
}
@@ -227,21 +242,27 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
return nil, 0, err
}
- query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
- category_id, base_unit_id, unit, status, version, created_at, updated_at
- FROM materials WHERE organization_id = ?`
+ query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
+ m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
+ m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
+ FROM materials m
+ LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
+ LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
+ LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
+ LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
+ WHERE m.organization_id = ?`
args := []interface{}{organizationID}
if status != "all" {
- query += ` AND status = ?`
+ query += ` AND m.status = ?`
args = append(args, status)
}
if search != "" {
- query += ` AND (material_code LIKE ? OR name LIKE ?)`
+ query += ` AND (m.material_code LIKE ? OR m.name LIKE ?)`
searchPattern := "%" + search + "%"
args = append(args, searchPattern, searchPattern)
}
- query += ` ORDER BY id DESC LIMIT ? OFFSET ?`
+ query += ` ORDER BY m.id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := database.DB.Query(query, args...)
@@ -255,8 +276,9 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
var m model.Material
var materialImage sql.NullString
var manufacturerModel sql.NullString
+ var supplierID, manufacturerID sql.NullInt64
err := rows.Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec,
- &manufacturerModel, &m.CategoryID, &m.BaseUnitID,
+ &manufacturerModel, &supplierID, &manufacturerID, &m.SupplierName, &m.ManufacturerName, &m.CategoryID, &m.BaseUnitID,
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return nil, 0, err
@@ -267,11 +289,112 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
if manufacturerModel.Valid {
m.ManufacturerModel = &manufacturerModel.String
}
+ if supplierID.Valid {
+ m.SupplierID = &supplierID.Int64
+ }
+ if manufacturerID.Valid {
+ m.ManufacturerID = &manufacturerID.Int64
+ }
materials = append(materials, m)
}
return materials, total, nil
}
+// FindExactDuplicates 返回名称、规格、单位及供应商/制造商全部相同的本公司物料。
+func (r *MaterialRepo) FindExactDuplicates(organizationID int64, request *model.MaterialDuplicateCheckRequest) ([]model.Material, error) {
+ query := `SELECT id FROM materials
+ WHERE organization_id = ? AND name = ?
+ AND COALESCE(spec, '') = ? AND COALESCE(unit, '') = ?
+ AND COALESCE(supplier_id, 0) = COALESCE(?, 0)
+ AND COALESCE(manufacturer_id, 0) = COALESCE(?, 0)`
+ args := []interface{}{organizationID, strings.TrimSpace(request.Name), request.Spec, request.Unit,
+ request.SupplierID, request.ManufacturerID}
+ if request.ExcludeID > 0 {
+ query += ` AND id <> ?`
+ args = append(args, request.ExcludeID)
+ }
+ query += ` ORDER BY id DESC`
+ rows, err := database.DB.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0)
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ items := make([]model.Material, 0, len(ids))
+ for _, id := range ids {
+ var item model.Material
+ if err := r.GetByID(organizationID, id, &item); err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ return items, nil
+}
+
+// ListByNames loads only duplicate-check dimensions in bounded queries for an import batch.
+func (r *MaterialRepo) ListByNames(organizationID int64, names []string) ([]model.Material, error) {
+ const batchSize = 400
+ items := make([]model.Material, 0)
+ for start := 0; start < len(names); start += batchSize {
+ end := start + batchSize
+ if end > len(names) {
+ end = len(names)
+ }
+ batch := names[start:end]
+ placeholders := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
+ args := make([]interface{}, 0, len(batch)+1)
+ args = append(args, organizationID)
+ for _, name := range batch {
+ args = append(args, strings.TrimSpace(name))
+ }
+ rows, err := database.DB.Query(`SELECT id, material_code, name, COALESCE(spec, ''), COALESCE(unit, ''), base_unit_id, supplier_id, manufacturer_id
+ FROM materials WHERE organization_id = ? AND name IN (`+placeholders+`)`, args...)
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var item model.Material
+ var baseUnitID, supplierID, manufacturerID sql.NullInt64
+ if err := rows.Scan(&item.ID, &item.MaterialCode, &item.Name, &item.Spec, &item.Unit, &baseUnitID, &supplierID, &manufacturerID); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if baseUnitID.Valid {
+ item.BaseUnitID = &baseUnitID.Int64
+ }
+ if supplierID.Valid {
+ item.SupplierID = &supplierID.Int64
+ }
+ if manufacturerID.Valid {
+ item.ManufacturerID = &manufacturerID.Int64
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ }
+ return items, nil
+}
+
func (r *MaterialRepo) Update(organizationID, id int64, req *model.UpdateMaterialRequest) error {
var sets []string
var args []interface{}
@@ -304,6 +427,14 @@ func (r *MaterialRepo) Update(organizationID, id int64, req *model.UpdateMateria
sets = append(sets, "base_unit_id = ?")
args = append(args, req.BaseUnitID)
}
+ if req.SupplierID != nil {
+ sets = append(sets, "supplier_id = NULLIF(?, 0)")
+ args = append(args, *req.SupplierID)
+ }
+ if req.ManufacturerID != nil {
+ sets = append(sets, "manufacturer_id = NULLIF(?, 0)")
+ args = append(args, *req.ManufacturerID)
+ }
if req.Status != "" {
sets = append(sets, "status = ?")
args = append(args, req.Status)
@@ -544,15 +675,15 @@ func nextAvailableMaterialCode(rule *model.CodeRule, start int64, taken map[stri
// buildMaterialInsertBatch 拼一条多值 INSERT,返回 SQL 与按顺序展开的参数。
func buildMaterialInsertBatch(batch []*model.Material) (string, []interface{}) {
var builder strings.Builder
- builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES `)
- args := make([]interface{}, 0, len(batch)*11)
+ builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES `)
+ args := make([]interface{}, 0, len(batch)*13)
for i, m := range batch {
if i > 0 {
builder.WriteString(", ")
}
- builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
+ builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
args = append(args, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
- m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
+ m.ManufacturerModel, m.SupplierID, m.ManufacturerID, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
}
return builder.String(), args
}
diff --git a/internal/repository/material_import_test.go b/internal/repository/material_import_test.go
index a231f437..9081a625 100644
--- a/internal/repository/material_import_test.go
+++ b/internal/repository/material_import_test.go
@@ -13,7 +13,7 @@ import (
const importLockRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`
-const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
@@ -48,8 +48,8 @@ func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
// 两行折叠成一条多值 INSERT
mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
WithArgs(
- int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1,
- int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1,
+ int64(1), "A010202", "螺栓", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1,
+ int64(1), "A010203", "螺母", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1,
).
WillReturnResult(sqlmock.NewResult(31, 2))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).
diff --git a/internal/repository/material_test.go b/internal/repository/material_test.go
index dee424e6..152e96dc 100644
--- a/internal/repository/material_test.go
+++ b/internal/repository/material_test.go
@@ -23,13 +23,19 @@ func TestMaterialRepoGetByIDReturnsOnlyMasterData(t *testing.T) {
})
database.DB = db
- query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
- category_id, base_unit_id, unit, status, version, created_at, updated_at
- FROM materials WHERE organization_id = ? AND id = ?`
+ query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
+ m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
+ m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
+ FROM materials m
+ LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
+ LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
+ LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
+ LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
+ WHERE m.organization_id = ? AND m.id = ?`
now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(query)).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, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "active", 2, now, now),
+ sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
+ AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, nil, nil, "", "", 12, 3, "件", "active", 2, now, now),
)
var material model.Material
diff --git a/internal/repository/partner.go b/internal/repository/partner.go
index a4839fa2..0c6327cb 100644
--- a/internal/repository/partner.go
+++ b/internal/repository/partner.go
@@ -12,8 +12,46 @@ import (
type PartnerRepository struct{}
+var ErrMaterialPartnerNotFound = errors.New("material partner not found for role")
+var ErrMaterialPartnerAmbiguous = errors.New("material partner name is ambiguous")
+
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} }
+// FindMaterialPartner resolves an organization partner by its exact display name or code
+// and verifies that it has the requested material role.
+func (r *PartnerRepository) FindMaterialPartner(organizationID int64, role, value string) (int64, error) {
+ field := "op.is_supplier"
+ if role == "manufacturer" {
+ field = "op.is_manufacturer"
+ }
+ rows, err := database.DB.Query(`SELECT DISTINCT op.id FROM organization_partners op
+ JOIN business_partners bp ON bp.id = op.business_partner_id
+ WHERE op.organization_id = ? AND `+field+` = TRUE AND (bp.legal_name = ? OR op.partner_code = ?) LIMIT 2`,
+ organizationID, value, value)
+ if err != nil {
+ return 0, err
+ }
+ defer rows.Close()
+ var ids []int64
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ return 0, err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+ if len(ids) == 0 {
+ return 0, fmt.Errorf("%w: %s", ErrMaterialPartnerNotFound, value)
+ }
+ if len(ids) > 1 {
+ return 0, fmt.Errorf("%w: %s", ErrMaterialPartnerAmbiguous, value)
+ }
+ return ids[0], nil
+}
+
func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) {
// 角色过滤写成一份,由计数和取数共用:两处各写一遍,改一处漏一处就会让
// 总数和列表对不上(总数算的是全部,列表却只有供应商)。
@@ -27,6 +65,9 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
if role == "customer" {
filter += ` AND op.is_customer = TRUE`
}
+ if role == "manufacturer" {
+ filter += ` AND op.is_manufacturer = TRUE`
+ }
if category != "" {
// 字段名仅由已知角色选择,筛选值始终使用参数绑定。
field := "op.supplier_categories"
@@ -55,7 +96,7 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
- COALESCE(op.payment_terms, ''), op.supplier_categories, op.customer_categories
+ COALESCE(op.payment_terms, ''), op.supplier_categories, op.customer_categories, op.is_manufacturer
` + filter + `
ORDER BY op.partner_code
LIMIT ? OFFSET ?`
@@ -73,7 +114,7 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode,
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus,
- &item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms, &supplierCategories, &customerCategories); err != nil {
+ &item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms, &supplierCategories, &customerCategories, &item.IsManufacturer); err != nil {
return nil, 0, err
}
item.SupplierCategories = splitPartnerCategories(supplierCategories)
@@ -108,11 +149,11 @@ func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) {
result, err = tx.Exec(`INSERT INTO organization_partners
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
- supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
- VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
+ supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories, is_manufacturer)
+ VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?)`,
item.OrganizationID, item.BusinessPartnerID, item.PartnerCode, item.IsSupplier, item.IsCustomer,
item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms,
- strings.Join(item.SupplierCategories, ","), strings.Join(item.CustomerCategories, ","))
+ strings.Join(item.SupplierCategories, ","), strings.Join(item.CustomerCategories, ","), item.IsManufacturer)
if err != nil {
return err
}
@@ -151,11 +192,11 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) {
if _, err = tx.Exec(`UPDATE organization_partners SET is_supplier = ?, is_customer = ?,
supplier_status = NULLIF(?, ''), customer_status = NULLIF(?, ''),
settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, ''),
- supplier_categories = COALESCE(?, supplier_categories), customer_categories = COALESCE(?, customer_categories)
+ supplier_categories = COALESCE(?, supplier_categories), customer_categories = COALESCE(?, customer_categories), is_manufacturer = ?
WHERE id = ? AND organization_id = ?`,
item.IsSupplier, item.IsCustomer, item.SupplierStatus, item.CustomerStatus,
item.SettlementCurrency, item.PaymentTerms, optionalPartnerCategories(item.SupplierCategories),
- optionalPartnerCategories(item.CustomerCategories), item.ID, item.OrganizationID); err != nil {
+ optionalPartnerCategories(item.CustomerCategories), item.IsManufacturer, item.ID, item.OrganizationID); err != nil {
return err
}
if err = tx.Commit(); err != nil {
diff --git a/internal/repository/partner_manufacturer_test.go b/internal/repository/partner_manufacturer_test.go
new file mode 100644
index 00000000..aeb1ae62
--- /dev/null
+++ b/internal/repository/partner_manufacturer_test.go
@@ -0,0 +1,44 @@
+package repository
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/wmms/wmms/pkg/database"
+)
+
+func TestManufacturerListIsOrganizationScopedAndReturnsRole(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ database.DB = db
+ t.Cleanup(func() { database.DB = nil; db.Close() })
+ mock.ExpectQuery(`SELECT COUNT\(\*\).*WHERE op.organization_id = \? AND op.is_manufacturer = TRUE`).WithArgs(int64(2)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
+ mock.ExpectQuery(`SELECT op.id.*WHERE op.organization_id = \? AND op.is_manufacturer = TRUE`).WithArgs(int64(2), 20, 0).WillReturnRows(sqlmock.NewRows([]string{
+ "id", "organization_id", "business_partner_id", "partner_code", "legal_name", "unified_credit_code", "contact_name", "contact_phone", "address",
+ "is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories", "is_manufacturer",
+ }).AddRow(3, 2, 4, "MFG-001", "制造企业", "", "", "", "", false, false, "", "", "", "", "", "", true))
+ items, total, err := NewPartnerRepository().List(2, "manufacturer", 20, 0, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if total != 1 || len(items) != 1 {
+ t.Fatalf("unexpected list: %v / %v", items, total)
+ }
+ body, err := json.Marshal(items[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var result map[string]interface{}
+ if err := json.Unmarshal(body, &result); err != nil {
+ t.Fatal(err)
+ }
+ if result["is_manufacturer"] != true {
+ t.Fatalf("manufacturer flag lost: %s", body)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/repository/partner_test.go b/internal/repository/partner_test.go
index b0d9b4d8..b6ff36aa 100644
--- a/internal/repository/partner_test.go
+++ b/internal/repository/partner_test.go
@@ -24,6 +24,7 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
PartnerCode: "SUP-001",
LegalName: "示例供应商",
IsSupplier: true,
+ IsManufacturer: true,
SupplierStatus: "active",
}
if err := json.Unmarshal([]byte(`{"supplier_categories":["material","finished_product"],"customer_categories":[]}`), partner); err != nil {
@@ -38,9 +39,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
- supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
- VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`)).
- WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "", "material,finished_product", "").
+ supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories, is_manufacturer)
+ VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?)`)).
+ WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "", "material,finished_product", "", true).
WillReturnResult(sqlmock.NewResult(15, 1))
mock.ExpectCommit()
@@ -65,9 +66,9 @@ func TestPartnerListReturnsIndependentCategoriesAndEmptyArrays(t *testing.T) {
mock.ExpectQuery("SELECT COUNT").WithArgs(int64(1)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
mock.ExpectQuery("SELECT op.id").WithArgs(int64(1), 20, 0).WillReturnRows(sqlmock.NewRows([]string{
"id", "organization_id", "business_partner_id", "partner_code", "legal_name", "unified_credit_code", "contact_name", "contact_phone", "address",
- "is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories",
- }).AddRow(1, 1, 10, "P-001", "公司", "", "", "", "", true, true, "active", "active", "", "", "material,finished_product", "finished_product").
- AddRow(2, 1, 11, "P-002", "历史公司", "", "", "", "", true, false, "active", "", "", "", "", ""))
+ "is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories", "is_manufacturer",
+ }).AddRow(1, 1, 10, "P-001", "公司", "", "", "", "", true, true, "active", "active", "", "", "material,finished_product", "finished_product", false).
+ AddRow(2, 1, 11, "P-002", "历史公司", "", "", "", "", true, false, "active", "", "", "", "", "", false))
items, total, err := NewPartnerRepository().List(1, "supplier", 20, 0, "")
if err != nil {
t.Fatal(err)
@@ -110,7 +111,7 @@ func TestPartnerUpdatePreservesOmittedCategoriesAndAllowsClearing(t *testing.T)
mock.ExpectQuery("SELECT business_partner_id").WithArgs(int64(12), int64(2)).WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(7))
mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE organization_partners SET.*supplier_categories = COALESCE.*customer_categories = COALESCE").
- WithArgs(true, true, "", "", "", "", tc.supplier, tc.customer, int64(12), int64(2)).WillReturnResult(sqlmock.NewResult(0, 1))
+ WithArgs(true, true, "", "", "", "", tc.supplier, tc.customer, false, int64(12), int64(2)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
if err := NewPartnerRepository().Update(&item); err != nil {
t.Fatal(err)
diff --git a/internal/service/admin_master.go b/internal/service/admin_master.go
index 24bdfa93..aeee392e 100644
--- a/internal/service/admin_master.go
+++ b/internal/service/admin_master.go
@@ -299,8 +299,8 @@ func (s *PartnerService) List(orgID int64, role string, limit, offset int, categ
if orgID <= 0 {
return nil, 0, ErrInvalidOrganizationID
}
- if role != "" && role != "supplier" && role != "customer" {
- return nil, 0, errors.New("role must be supplier or customer")
+ if role != "" && role != "supplier" && role != "manufacturer" && role != "customer" {
+ return nil, 0, errors.New("role must be supplier, manufacturer or customer")
}
return s.repo.List(orgID, role, limit, offset, category)
}
@@ -340,12 +340,12 @@ func (s *PartnerService) Update(item *model.BusinessPartner) error {
}
// normalizePartner 是新建与更新共用的字段清理、默认值补全和角色校验:
-// 至少要有供应商或客户其中一个角色,编辑时也可能把两个都取消掉。
+// 至少要有供应商、制造商或客户其中一个角色。
// 编码是否必填由两个入口各自判断。
func normalizePartner(item *model.BusinessPartner) error {
item.PartnerCode = strings.TrimSpace(item.PartnerCode)
item.LegalName = strings.TrimSpace(item.LegalName)
- if !item.IsSupplier && !item.IsCustomer {
+ if !item.IsSupplier && !item.IsManufacturer && !item.IsCustomer {
return errors.New("at least one partner role is required")
}
for _, categories := range []*[]string{&item.SupplierCategories, &item.CustomerCategories} {
diff --git a/internal/service/inbound_test.go b/internal/service/inbound_test.go
index d9fe0e90..90b5172e 100644
--- a/internal/service/inbound_test.go
+++ b/internal/service/inbound_test.go
@@ -34,7 +34,7 @@ func TestInboundCreateFillsOrderDimensionsFromResolvedScope(t *testing.T) {
mock.ExpectExec("UPDATE inbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO inbound_items").WithArgs(int64(5), int64(7), 2).WillReturnResult(sqlmock.NewResult(6, 1))
// 明细补物料编码/名称时再读一次物料。
- mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
+ mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "in", 2, "inbound", int64(5)).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
diff --git a/internal/service/material.go b/internal/service/material.go
index 25041a1b..ee451cad 100644
--- a/internal/service/material.go
+++ b/internal/service/material.go
@@ -14,6 +14,9 @@ var ErrInvalidMaterialCategory = errors.New("请选择有效的小类类型")
var ErrMaterialNameTooLong = errors.New("物料名称过长,请小于200字")
var ErrMaterialSpecTooLong = errors.New("物料规格过长,请小于200字")
+var ErrInvalidMaterialSupplier = errors.New("请选择本公司的有效供应商")
+var ErrInvalidMaterialManufacturer = errors.New("请选择本公司的有效制造商")
+var ErrMaterialDuplicate = errors.New("发现名称、规格、单位、供应商和制造商均相同的物料,请先检查重复项")
const maxMaterialNameLength = 200
const maxMaterialSpecLength = 200
@@ -67,6 +70,36 @@ func (s *MaterialService) validateUnit(unitID *int64, organizationID int64) erro
return nil
}
+func (s *MaterialService) validateMaterialPartner(id *int64, organizationID int64, role string) error {
+ if id == nil || *id == 0 {
+ return nil
+ }
+ if *id < 0 {
+ if role == "manufacturer" {
+ return ErrInvalidMaterialManufacturer
+ }
+ return ErrInvalidMaterialSupplier
+ }
+ valid, err := s.repo.IsPartnerForRole(*id, organizationID, role)
+ if err != nil {
+ return err
+ }
+ if !valid {
+ if role == "manufacturer" {
+ return ErrInvalidMaterialManufacturer
+ }
+ return ErrInvalidMaterialSupplier
+ }
+ return nil
+}
+
+func optionalMaterialID(id *int64) *int64 {
+ if id == nil || *id <= 0 {
+ return nil
+ }
+ return id
+}
+
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
organizationID := req.OrganizationID
if organizationID <= 0 {
@@ -85,10 +118,28 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
if err := s.validateUnit(req.BaseUnitID, organizationID); err != nil {
return nil, err
}
+ if err := s.validateMaterialPartner(req.SupplierID, organizationID, "supplier"); err != nil {
+ return nil, err
+ }
+ if err := s.validateMaterialPartner(req.ManufacturerID, organizationID, "manufacturer"); err != nil {
+ return nil, err
+ }
unit := req.Unit
if unit == "" {
unit = "个"
}
+ if !req.AllowDuplicate {
+ matches, err := s.repo.FindExactDuplicates(organizationID, &model.MaterialDuplicateCheckRequest{
+ Name: req.Name, Spec: req.Spec, Unit: unit,
+ BaseUnitID: req.BaseUnitID, SupplierID: optionalMaterialID(req.SupplierID), ManufacturerID: optionalMaterialID(req.ManufacturerID),
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(matches) > 0 {
+ return nil, ErrMaterialDuplicate
+ }
+ }
var materialImage *string
if req.MaterialImage != nil {
@@ -100,6 +151,8 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
MaterialCode: req.MaterialCode,
Name: req.Name,
MaterialImage: materialImage,
+ SupplierID: optionalMaterialID(req.SupplierID),
+ ManufacturerID: optionalMaterialID(req.ManufacturerID),
Spec: req.Spec,
CategoryID: req.CategoryID,
BaseUnitID: req.BaseUnitID,
@@ -191,6 +244,16 @@ func (s *MaterialService) List(organizationID int64, search, status string, limi
return s.repo.List(organizationID, search, status, limit, offset)
}
+func (s *MaterialService) FindExactDuplicates(organizationID int64, req *model.MaterialDuplicateCheckRequest) ([]model.Material, error) {
+ if err := s.validateMaterialPartner(req.SupplierID, organizationID, "supplier"); err != nil {
+ return nil, err
+ }
+ if err := s.validateMaterialPartner(req.ManufacturerID, organizationID, "manufacturer"); err != nil {
+ return nil, err
+ }
+ return s.repo.FindExactDuplicates(organizationID, req)
+}
+
func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error) {
// 获取修改前的数据;不存在或不属于本公司时直接以 sql.ErrNoRows 结束。
oldMaterial, err := s.GetByID(organizationID, id)
@@ -217,6 +280,16 @@ func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMate
return nil, err
}
}
+ if req.SupplierID != nil && (oldMaterial.SupplierID == nil || *req.SupplierID != *oldMaterial.SupplierID) {
+ if err := s.validateMaterialPartner(req.SupplierID, organizationID, "supplier"); err != nil {
+ return nil, err
+ }
+ }
+ if req.ManufacturerID != nil && (oldMaterial.ManufacturerID == nil || *req.ManufacturerID != *oldMaterial.ManufacturerID) {
+ if err := s.validateMaterialPartner(req.ManufacturerID, organizationID, "manufacturer"); err != nil {
+ return nil, err
+ }
+ }
err = s.repo.Update(organizationID, id, req)
if err != nil {
diff --git a/internal/service/material_import.go b/internal/service/material_import.go
index 4f45ee49..648c1cce 100644
--- a/internal/service/material_import.go
+++ b/internal/service/material_import.go
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
+ "errors"
"fmt"
"io"
"strings"
@@ -17,6 +18,7 @@ type MaterialImportService struct {
masterRepo *repository.MasterDataRepository
logRepo *repository.MaterialLogRepo
importRepo *repository.MaterialImportLogRepo
+ partnerRepo *repository.PartnerRepository
}
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
@@ -24,6 +26,7 @@ type materialImportItem struct {
*model.Material
categoryLabel string
unitLabel string
+ row int
}
func NewMaterialImportService() *MaterialImportService {
@@ -32,6 +35,7 @@ func NewMaterialImportService() *MaterialImportService {
masterRepo: repository.NewMasterDataRepository(),
logRepo: repository.NewMaterialLogRepo(),
importRepo: repository.NewMaterialImportLogRepo(),
+ partnerRepo: repository.NewPartnerRepository(),
}
}
@@ -133,6 +137,29 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
materials := make([]*materialImportItem, 0, len(rows))
importErrors := make([]model.MaterialImportError, 0)
+ uniqueNames := make([]string, 0, len(rows))
+ nameSeen := make(map[string]struct{}, len(rows))
+ for _, row := range rows {
+ name := strings.TrimSpace(row.Name)
+ if name != "" {
+ if _, exists := nameSeen[name]; !exists {
+ nameSeen[name] = struct{}{}
+ uniqueNames = append(uniqueNames, name)
+ }
+ }
+ }
+ existingMaterials, err := s.materialRepo.ListByNames(organizationID, uniqueNames)
+ if err != nil {
+ return nil, nil, err
+ }
+ existingByKey := make(map[materialDuplicateKey]*model.Material, len(existingMaterials))
+ for i := range existingMaterials {
+ item := &existingMaterials[i]
+ existingByKey[duplicateKey(item.Name, item.Spec, item.Unit, item.SupplierID, item.ManufacturerID)] = item
+ }
+ seenInFile := make(map[materialDuplicateKey]int)
+ partnerIDs := make(map[string]int64)
+ partnerProblems := make(map[string]error)
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
// 逐行去查会把一次导入放大成两万次往返。
ruleAvailable := make(map[int64]bool)
@@ -181,6 +208,44 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
if unit == nil {
unit = unitByCode[unitName]
}
+
+ resolvePartner := func(role, value string) (*int64, string, error) {
+ if value == "" {
+ return nil, "", nil
+ }
+ key := role + "\x00" + value
+ if problem, cached := partnerProblems[key]; cached {
+ return nil, partnerProblemText(problem, role, value), nil
+ }
+ if id, cached := partnerIDs[key]; cached {
+ copyID := id
+ return ©ID, "", nil
+ }
+ id, err := s.partnerRepo.FindMaterialPartner(organizationID, role, value)
+ if errors.Is(err, repository.ErrMaterialPartnerNotFound) || errors.Is(err, repository.ErrMaterialPartnerAmbiguous) {
+ partnerProblems[key] = err
+ return nil, partnerProblemText(err, role, value), nil
+ }
+ if err != nil {
+ return nil, "", err
+ }
+ partnerIDs[key] = id
+ return &id, "", nil
+ }
+ supplierID, supplierProblem, err := resolvePartner("supplier", row.Supplier)
+ if err != nil {
+ return nil, nil, err
+ }
+ if supplierProblem != "" {
+ reasons = append(reasons, supplierProblem)
+ }
+ manufacturerID, manufacturerProblem, err := resolvePartner("manufacturer", row.Manufacturer)
+ if err != nil {
+ return nil, nil, err
+ }
+ if manufacturerProblem != "" {
+ reasons = append(reasons, manufacturerProblem)
+ }
if unit == nil {
if row.Unit == "" {
reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位")
@@ -222,21 +287,58 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
// 模板里填的是名称时也落编码:编码是单位表的唯一键,名称是可变的显示名,
// 物料表的 unit 只该有一种写法(与单条新增一致),否则库存页直接显示该
// 字段的地方会出现「千克」「kg」并存。
- materials = append(materials, &materialImportItem{Material: &model.Material{
+ material := &model.Material{
OrganizationID: organizationID,
Name: name,
Spec: row.Spec,
+ SupplierID: supplierID,
+ ManufacturerID: manufacturerID,
CategoryID: &categoryID,
BaseUnitID: baseUnitID,
Unit: unit.Code,
Status: "active",
Version: 1,
- }, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name})
+ }
+ key := duplicateKey(material.Name, material.Spec, material.Unit, material.SupplierID, material.ManufacturerID)
+ if duplicate := existingByKey[key]; duplicate != nil {
+ importErrors = append(importErrors, model.MaterialImportError{Row: row.Row, Data: row.Data(), Reason: fmt.Sprintf("与现有物料「%s」(编码 %s)完全重复", duplicate.Name, duplicate.MaterialCode)})
+ continue
+ }
+ if duplicateRow := seenInFile[key]; duplicateRow > 0 {
+ importErrors = append(importErrors, model.MaterialImportError{Row: row.Row, Data: row.Data(), Reason: fmt.Sprintf("与本文件第 %d 行物料完全重复", duplicateRow)})
+ continue
+ }
+ seenInFile[key] = row.Row
+ materials = append(materials, &materialImportItem{Material: material, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name, row: row.Row})
}
return materials, importErrors, nil
}
+type materialDuplicateKey struct {
+ name, spec, unit string
+ supplierID, manufacturerID int64
+}
+
+func duplicateKey(name, spec, unit string, supplierID, manufacturerID *int64) materialDuplicateKey {
+ key := materialDuplicateKey{name: strings.TrimSpace(name), spec: strings.TrimSpace(spec), unit: strings.TrimSpace(unit)}
+ if supplierID != nil {
+ key.supplierID = *supplierID
+ }
+ if manufacturerID != nil {
+ key.manufacturerID = *manufacturerID
+ }
+ return key
+}
+
+func partnerProblemText(err error, role, value string) string {
+ roleLabel := map[string]string{"supplier": "供应商", "manufacturer": "制造商"}[role]
+ if errors.Is(err, repository.ErrMaterialPartnerAmbiguous) {
+ return fmt.Sprintf("%s「%s」对应多条记录,请改用伙伴编码", roleLabel, value)
+ }
+ return fmt.Sprintf("%s「%s」不存在或不具备对应身份", roleLabel, value)
+}
+
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error {
tx, err := repository.BeginTx()
diff --git a/internal/service/material_import_excel.go b/internal/service/material_import_excel.go
index 9da01da4..250af65e 100644
--- a/internal/service/material_import_excel.go
+++ b/internal/service/material_import_excel.go
@@ -29,10 +29,10 @@ var (
)
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
-var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"}
+var materialImportHeaders = []string{"物料名称", "规格", "单位", "供应商", "制造商", "大类", "小类"}
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
-// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
+// 模板不合法。规格、单位、供应商、制造商均为选填,用户删掉空列也不拦。
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
@@ -51,25 +51,27 @@ func BuildMaterialImportTemplate() ([]byte, error) {
{"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"},
{"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"},
{"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
+ {"供应商", "选填", "示例供应商或供应商编码", "填写当前组织已有供应商的名称或编码;留空表示不指定。"},
+ {"制造商", "选填", "示例制造商或制造商编码", "填写当前组织已有制造商的名称或编码;留空表示不指定。"},
{"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
{"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
}
- sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 12: "二、填写与上传步骤", 18: "三、导入注意事项", 25: "四、填写示例(仅供参考)"}
+ sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 14: "二、填写与上传步骤", 20: "三、导入注意事项", 27: "四、填写示例(仅供参考)"}
notes := map[int]string{
2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
- 13: "1. 确认页面当前组织,核对该组织的分类、单位及小类编码规则。",
- 14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
- 15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
- 16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
- 19: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
- 20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。",
- 21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
- 22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。",
- 23: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
- 30: "示例只展示写法,请替换为当前组织实际启用的分类与单位;示例不在数据页,不会自动导入。",
+ 15: "1. 确认页面当前组织,核对该组织的分类、单位、供应商、制造商及小类编码规则。",
+ 16: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
+ 17: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
+ 18: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
+ 21: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
+ 22: "整批校验:任意一行校验失败(包含完全重复物料),本次所有物料均不导入;请全部修正后重传。",
+ 23: "重复检查:名称、规格、单位、供应商和制造商全部相同才判为重复;同一文件内也会检查。",
+ 24: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
+ 25: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
+ 32: "示例只展示写法,请替换为当前组织实际启用的数据;示例不在数据页,不会自动导入。",
}
for row, text := range sections {
- if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
+ if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row)); err != nil {
return nil, err
}
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
@@ -77,7 +79,7 @@ func BuildMaterialImportTemplate() ([]byte, error) {
}
}
for row, text := range notes {
- if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
+ if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row)); err != nil {
return nil, err
}
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
@@ -92,14 +94,14 @@ func BuildMaterialImportTemplate() ([]byte, error) {
return nil, err
}
}
- if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("E%d", i+5)); err != nil {
+ if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("G%d", i+5)); err != nil {
return nil, err
}
}
- sampleRows := [][]string{materialImportHeaders, {"内六角螺栓", "M8×30", "个", "机械结构类", "标准件"}, {"六角螺母", "M8", "个", "机械结构类", "标准件"}}
+ sampleRows := [][]string{materialImportHeaders, {"内六角螺栓", "M8×30", "个", "示例供应商", "示例制造商", "机械结构类", "标准件"}, {"六角螺母", "M8", "个", "", "", "机械结构类", "标准件"}}
for i, row := range sampleRows {
for j, text := range row {
- cell, _ := excelize.CoordinatesToCellName(j+1, i+26)
+ cell, _ := excelize.CoordinatesToCellName(j+1, i+28)
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
return nil, err
}
@@ -140,64 +142,64 @@ func styleMaterialImportTemplate(f *excelize.File) error {
}
ids[i] = id
}
- if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E30", ids[0]); err != nil {
+ if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "G32", ids[0]); err != nil {
return err
}
- if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "E2", ids[5]); err != nil {
+ if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "G2", ids[5]); err != nil {
return err
}
- if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E1", ids[1]); err != nil {
+ if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "G1", ids[1]); err != nil {
return err
}
- for _, row := range []int{4, 12, 18, 25} {
- if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[2]); err != nil {
+ for _, row := range []int{4, 14, 20, 27} {
+ if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row), ids[2]); err != nil {
return err
}
}
- for _, row := range []int{5, 26} {
- if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[3]); err != nil {
+ for _, row := range []int{5, 28} {
+ if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row), ids[3]); err != nil {
return err
}
}
- if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A10", ids[6]); err != nil {
+ if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A12", ids[6]); err != nil {
return err
}
- for _, cell := range []string{"B6", "B9", "B10"} {
+ for _, cell := range []string{"B6", "B11", "B12"} {
if err := f.SetCellStyle(MaterialImportSheetGuide, cell, cell, ids[7]); err != nil {
return err
}
}
- for i, width := range []float64{20, 12, 24, 34} {
+ for i, width := range []float64{20, 12, 24, 28, 18, 34, 18} {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := f.SetColWidth(MaterialImportSheetGuide, col, col, width); err != nil {
return err
}
}
- for row := 1; row <= 30; row++ {
+ for row := 1; row <= 32; row++ {
height := 30.0
if row == 1 {
height = 42
}
- if row >= 6 && row <= 10 {
+ if row >= 6 && row <= 12 {
height = 54
}
- if row >= 19 && row <= 23 || row == 30 {
+ if row >= 21 && row <= 25 || row == 32 {
height = 42
}
if err := f.SetRowHeight(MaterialImportSheetGuide, row, height); err != nil {
return err
}
}
- if err := f.SetColStyle(MaterialImportSheetData, "A:E", ids[4]); err != nil {
+ if err := f.SetColStyle(MaterialImportSheetData, "A:G", ids[4]); err != nil {
return err
}
- if err := f.SetCellStyle(MaterialImportSheetData, "A1", "E1", ids[3]); err != nil {
+ if err := f.SetCellStyle(MaterialImportSheetData, "A1", "G1", ids[3]); err != nil {
return err
}
if err := f.SetRowHeight(MaterialImportSheetData, 1, 32); err != nil {
return err
}
- for i, width := range []float64{34, 28, 16, 26} {
+ for i, width := range []float64{34, 28, 16, 28, 28, 26, 18} {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := f.SetColWidth(MaterialImportSheetData, col, col, width); err != nil {
return err
@@ -257,14 +259,16 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
return nil, err
}
row := model.MaterialImportRow{
- Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
- Name: materialImportColumnValue(raw, columns, "物料名称"),
- Spec: materialImportColumnValue(raw, columns, "规格"),
- Unit: materialImportColumnValue(raw, columns, "单位"),
- MajorName: materialImportColumnValue(raw, columns, "大类"),
- MinorName: materialImportColumnValue(raw, columns, "小类"),
+ Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
+ Name: materialImportColumnValue(raw, columns, "物料名称"),
+ Spec: materialImportColumnValue(raw, columns, "规格"),
+ Unit: materialImportColumnValue(raw, columns, "单位"),
+ Supplier: materialImportColumnValue(raw, columns, "供应商"),
+ Manufacturer: materialImportColumnValue(raw, columns, "制造商"),
+ MajorName: materialImportColumnValue(raw, columns, "大类"),
+ MinorName: materialImportColumnValue(raw, columns, "小类"),
}
- if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
+ if row.Name == "" && row.Spec == "" && row.Unit == "" && row.Supplier == "" && row.Manufacturer == "" && row.MajorName == "" && row.MinorName == "" {
continue
}
if len(rows) == MaterialImportMaxRows {
diff --git a/internal/service/material_import_excel_test.go b/internal/service/material_import_excel_test.go
index 44b191e9..d3814f36 100644
--- a/internal/service/material_import_excel_test.go
+++ b/internal/service/material_import_excel_test.go
@@ -234,7 +234,7 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
if err != nil {
t.Fatalf("read rows: %v", err)
}
- if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" {
+ if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][6] != "小类" {
t.Fatalf("表头 = %v", headers)
}
// 模板必须能被自己的解析器接受(表头一致)
@@ -254,6 +254,14 @@ func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte {
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
t.Helper()
+ // 旧的五列测试数据为「名称、规格、单位、大类、小类」;补上新增的两个选填列。
+ if strings.Join(headers, "\x00") == strings.Join(materialImportHeaders, "\x00") {
+ for i, row := range dataRows {
+ if len(row) == 5 {
+ dataRows[i] = []string{row[0], row[1], row[2], "", "", row[3], row[4]}
+ }
+ }
+ }
f := excelize.NewFile()
defer f.Close()
f.SetSheetName("Sheet1", MaterialImportSheetData)
diff --git a/internal/service/material_import_test.go b/internal/service/material_import_test.go
index 9c6f5a3c..4462ffbf 100644
--- a/internal/service/material_import_test.go
+++ b/internal/service/material_import_test.go
@@ -17,6 +17,8 @@ import (
const importCategoryQuery = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`
const importUnitQuery = `SELECT id, organization_id, code, name, precision_scale, status FROM units WHERE organization_id = ? ORDER BY code`
const importRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'`
+const importExistingMaterialsQuery = `SELECT id, material_code, name, COALESCE(spec, ''), COALESCE(unit, ''), base_unit_id, supplier_id, manufacturer_id
+ FROM materials WHERE organization_id = ? AND name IN (?)`
func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
@@ -40,6 +42,8 @@ func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) {
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
AddRow(1, 1, "个", "个", 0, "active"))
+ mock.ExpectQuery(regexp.QuoteMeta(importExistingMaterialsQuery)).WithArgs(int64(1), sqlmock.AnyArg()).
+ WillReturnRows(sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "base_unit_id", "supplier_id", "manufacturer_id"}))
if withRule {
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
@@ -320,6 +324,8 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
AddRow(1, 1, "个", "个", 0, "active"))
+ mock.ExpectQuery(regexp.QuoteMeta(importExistingMaterialsQuery)).WithArgs(int64(1), "内六角螺栓").
+ WillReturnRows(sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "base_unit_id", "supplier_id", "manufacturer_id"}))
// 大类查不到,小类不再定位,也就不会去查编码规则
defer expectNoImportLog(t, mock)()
@@ -392,6 +398,9 @@ func TestMaterialImportServiceStoresUnitCodeWhenTemplateUsesUnitName(t *testing.
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
AddRow(7, 1, "kg", "千克", 3, "active"))
+ mock.ExpectQuery(`SELECT id, material_code, name, COALESCE\(spec, ''\), COALESCE\(unit, ''\), base_unit_id, supplier_id, manufacturer_id\s+FROM materials WHERE organization_id = \? AND name IN \(\?,\?\)`).
+ WithArgs(int64(1), "内六角螺栓", "垫圈").
+ WillReturnRows(sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "base_unit_id", "supplier_id", "manufacturer_id"}))
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
AddRow(5, 1, 12, "A01", 4, 201, "active"))
diff --git a/internal/service/material_test.go b/internal/service/material_test.go
index 4b64a992..db33c782 100644
--- a/internal/service/material_test.go
+++ b/internal/service/material_test.go
@@ -59,8 +59,8 @@ func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) {
query := getMaterialQuery
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(query)).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, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "obsolete", 1, now, now),
+ sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
+ AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, nil, nil, "", "", 12, 3, "件", "obsolete", 1, now, now),
)
active, err := NewMaterialService().IsActive(1, 7)
@@ -80,8 +80,8 @@ func TestMaterialServiceUpdatesWithoutChangingDisabledCategory(t *testing.T) {
t.Cleanup(cleanup)
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
materialRows := func(name string) *sqlmock.Rows {
- return 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, "MAT-007", name, nil, "", nil, 12, nil, "pcs", "active", 1, now, now)
+ return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
+ AddRow(7, 1, "MAT-007", name, nil, "", nil, nil, nil, "", "", 12, nil, "pcs", "active", 1, now, now)
}
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(materialRows("原名称"))
mock.ExpectExec(regexp.QuoteMeta("UPDATE materials SET name = ?, category_id = ?, version = version + 1 WHERE organization_id = ? AND id = ?")).
@@ -112,12 +112,18 @@ const (
)`
lockCodeRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`
codeTakenQuery = `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)`
- insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
advanceSequenceStmt = `UPDATE code_rules SET next_sequence = ? WHERE id = ?`
materialLogStmt = `INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES (?, ?, ?, ?, ?, ?)`
- getMaterialQuery = `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
- category_id, base_unit_id, unit, status, version, created_at, updated_at
- FROM materials WHERE organization_id = ? AND id = ?`
+ getMaterialQuery = `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
+ m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
+ m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
+ FROM materials m
+ LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
+ LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
+ LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
+ LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
+ WHERE m.organization_id = ? AND m.id = ?`
)
func newMaterialServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
@@ -156,15 +162,15 @@ 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, "个", "active", 1).
+ WithArgs(int64(1), code, "轴承", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1).
WillReturnResult(sqlmock.NewResult(7, 1))
}
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, "个", "active", 1, now, now),
+ sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
+ AddRow(7, 1, code, "轴承", nil, "", nil, nil, nil, "", "", 12, nil, "个", "active", 1, now, now),
)
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
@@ -179,6 +185,7 @@ func createMaterialWithCode(t *testing.T, code string) (*model.Material, error)
MaterialCode: code,
Name: "轴承",
CategoryID: &categoryID,
+ AllowDuplicate: true,
}, "tester")
}
@@ -289,7 +296,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, "个", "active", 1).
+ WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1).
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
mock.ExpectRollback()
diff --git a/internal/service/outbound_test.go b/internal/service/outbound_test.go
index b1df478a..f13a9cbe 100644
--- a/internal/service/outbound_test.go
+++ b/internal/service/outbound_test.go
@@ -26,13 +26,13 @@ func outboundMock(t *testing.T) sqlmock.Sqlmock {
// activeMaterialRow 造一行启用物料,供物料归属/存在性校验与明细补名使用。
func activeMaterialRow(id int64) *sqlmock.Rows {
- return 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(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now())
+ return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
+ AddRow(id, 1, "M-1", "Material", nil, "", nil, nil, nil, "", "", 1, 1, "pc", "active", 1, time.Now(), time.Now())
}
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {
for i := 0; i < 2; i++ {
- mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), id).
+ mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), id).
WillReturnRows(activeMaterialRow(id))
}
}
@@ -96,7 +96,7 @@ func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing
mock.ExpectExec("INSERT INTO outbound_orders").WillReturnResult(sqlmock.NewResult(11, 1))
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(12, 1))
- mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnError(errors.New("material read failed"))
+ mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), int64(7)).WillReturnError(errors.New("material read failed"))
mock.ExpectRollback()
_, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
if err == nil || err.Error() != "material read failed" {
@@ -183,7 +183,7 @@ func TestOutboundCreateFillsOrderDimensionsFromResolvedScope(t *testing.T) {
WillReturnResult(sqlmock.NewResult(11, 1))
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO outbound_items").WithArgs(int64(11), int64(7), 2).WillReturnResult(sqlmock.NewResult(12, 1))
- mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
+ mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "out", 2, "outbound", int64(11)).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
diff --git a/internal/service/partner_manufacturer_test.go b/internal/service/partner_manufacturer_test.go
new file mode 100644
index 00000000..c7777c64
--- /dev/null
+++ b/internal/service/partner_manufacturer_test.go
@@ -0,0 +1,41 @@
+package service
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/wmms/wmms/internal/model"
+ "github.com/wmms/wmms/pkg/database"
+)
+
+func TestManufacturerCanBeTheOnlyPartnerRole(t *testing.T) {
+ var item model.BusinessPartner
+ if err := json.Unmarshal([]byte(`{"is_manufacturer":true,"legal_name":"制造企业","partner_code":"MFG-001"}`), &item); err != nil {
+ t.Fatal(err)
+ }
+ if err := normalizePartner(&item); err != nil {
+ t.Fatalf("manufacturer role rejected: %v", err)
+ }
+}
+
+func TestManufacturerServiceListAcceptsRole(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ database.DB = db
+ t.Cleanup(func() { database.DB = nil; db.Close() })
+ mock.ExpectQuery(`SELECT COUNT\(\*\).*op.is_manufacturer = TRUE`).WithArgs(int64(2)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(0))
+ mock.ExpectQuery(`SELECT op.id.*op.is_manufacturer = TRUE`).WithArgs(int64(2), 20, 0).WillReturnRows(sqlmock.NewRows([]string{"id"}))
+ items, total, err := NewPartnerService().List(2, "manufacturer", 20, 0, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if total != 0 || len(items) != 0 {
+ t.Fatalf("unexpected list: %v / %v", items, total)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/scripts/init.sql b/scripts/init.sql
index e6272445..fd35b8b6 100644
--- a/scripts/init.sql
+++ b/scripts/init.sql
@@ -16,7 +16,7 @@ CREATE TABLE revoked_tokens (jti VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NUL
CREATE TABLE material_types (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_types_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE material_categories (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, description VARCHAR(1000) NOT NULL DEFAULT '', parent_id BIGINT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_categories_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(parent_id) REFERENCES material_categories(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
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 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), supplier_id BIGINT NULL COMMENT 'Default supplier organization_partner ID', manufacturer_id BIGINT NULL COMMENT 'Manufacturer organization_partner ID', 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;
@@ -24,7 +24,13 @@ CREATE TABLE tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT
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;
-- Partner categories support selecting material and finished_product together;
-- an empty set means unclassified. Both fields are created with the table.
-CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), supplier_categories SET('material','finished_product') NOT NULL DEFAULT '', customer_status VARCHAR(20), customer_categories SET('material','finished_product') NOT NULL DEFAULT '', settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_manufacturer BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), supplier_categories SET('material','finished_product') NOT NULL DEFAULT '', customer_status VARCHAR(20), customer_categories SET('material','finished_product') NOT NULL DEFAULT '', settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_id(organization_id,id), UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- Partner references must belong to the same organization as the material.
+ALTER TABLE materials
+ ADD KEY idx_materials_org_supplier(organization_id,supplier_id),
+ ADD KEY idx_materials_org_manufacturer(organization_id,manufacturer_id),
+ ADD CONSTRAINT fk_materials_supplier FOREIGN KEY(organization_id,supplier_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT,
+ ADD CONSTRAINT fk_materials_manufacturer FOREIGN KEY(organization_id,manufacturer_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT;
CREATE TABLE material_suppliers (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, organization_partner_id BIGINT NOT NULL, price DECIMAL(20,6) NOT NULL, currency CHAR(3) NOT NULL, minimum_order_quantity DECIMAL(20,6) NOT NULL DEFAULT 0, lead_time_days INT NOT NULL DEFAULT 0, is_preferred BOOLEAN NOT NULL DEFAULT FALSE, effective_from DATE NOT NULL, effective_to DATE, status VARCHAR(20) NOT NULL DEFAULT 'active', KEY idx_material_suppliers_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(organization_partner_id) REFERENCES organization_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE warehouses (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouses_org_code(organization_id,code), UNIQUE KEY uk_warehouses_organization(organization_id), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE warehouse_locations (id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouse_locations_warehouse_code(warehouse_id,code), UNIQUE KEY uk_warehouse_locations_warehouse(warehouse_id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -61,6 +67,8 @@ 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 schema_migrations(version,status,completed_at,details) VALUES('2026-10-12-material-partner-roles','completed',NOW(),JSON_OBJECT('version','2026-10-12-material-partner-roles','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
diff --git a/scripts/upgrades/2026-10-12-material-partner-roles.sql b/scripts/upgrades/2026-10-12-material-partner-roles.sql
new file mode 100644
index 00000000..3f4c0031
--- /dev/null
+++ b/scripts/upgrades/2026-10-12-material-partner-roles.sql
@@ -0,0 +1,20 @@
+-- 物料默认供应商、制造商及伙伴制造商身份。选择目标数据库后执行一次。
+-- 前置条件:已完成公司隔离升级,存在 schema_migrations。
+-- 不修改已有伙伴身份;物料新字段默认 NULL。
+SET NAMES utf8mb4;
+
+ALTER TABLE organization_partners
+ ADD COLUMN is_manufacturer BOOLEAN NOT NULL DEFAULT FALSE AFTER is_supplier,
+ ADD UNIQUE KEY uk_organization_partners_org_id(organization_id,id);
+
+ALTER TABLE materials
+ ADD COLUMN supplier_id BIGINT NULL COMMENT 'Default supplier organization_partner ID' AFTER manufacturer_model,
+ ADD COLUMN manufacturer_id BIGINT NULL COMMENT 'Manufacturer organization_partner ID' AFTER supplier_id,
+ ADD KEY idx_materials_org_supplier(organization_id,supplier_id),
+ ADD KEY idx_materials_org_manufacturer(organization_id,manufacturer_id),
+ ADD CONSTRAINT fk_materials_supplier FOREIGN KEY(organization_id,supplier_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT,
+ ADD CONSTRAINT fk_materials_manufacturer FOREIGN KEY(organization_id,manufacturer_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT;
+
+-- 复合外键约束组织归属,供应商/制造商角色匹配由业务接口校验。
+INSERT INTO schema_migrations(version,status,completed_at,details)
+VALUES('2026-10-12-material-partner-roles','completed',NOW(),JSON_OBJECT('version','2026-10-12-material-partner-roles','source','upgrade'));
diff --git a/启动项目.bat b/启动项目.bat
index 908cc320..6be36eeb 100644
--- a/启动项目.bat
+++ b/启动项目.bat
@@ -3,6 +3,40 @@ title Warehouse Management System Launcher
cd /d "%~dp0"
+echo Building frontend...
+pushd "%~dp0frontend"
+if not exist "node_modules\vite\bin\vite.js" (
+ call npm ci
+ if errorlevel 1 (
+ popd
+ echo Frontend dependency installation failed.
+ pause
+ exit /b 1
+ )
+)
+call npm run build
+if errorlevel 1 (
+ popd
+ echo Frontend build failed. Fix the errors above and run this launcher again.
+ pause
+ exit /b 1
+)
+popd
+
+rem go:embed resolves dist relative to internal/web, not the project root.
+echo Preparing embedded frontend...
+xcopy "%~dp0frontend\dist\*" "%~dp0internal\web\dist\" /E /I /H /Y >nul
+if errorlevel 1 (
+ echo Copying frontend build output failed.
+ pause
+ exit /b 1
+)
+if not exist "%~dp0internal\web\dist\index.html" (
+ echo Embedded frontend index.html is missing.
+ pause
+ exit /b 1
+)
+
echo Building backend...
rem Rebuild on every launch so the executable can never be a stale one.
go build -o server.exe ./cmd/server
@@ -13,6 +47,9 @@ if errorlevel 1 (
exit /b 1
)
+rem Optional validation mode: build both components without starting services.
+if /I "%~1"=="--build-only" exit /b 0
+
echo Starting backend (8082)...
rem Work dir must stay at the project root: frontend/dist and uploads are relative paths.
start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "server.exe"