diff --git a/internal/handler/material_organization_test.go b/internal/handler/material_organization_test.go index 8cab99e6..2287d765 100644 --- a/internal/handler/material_organization_test.go +++ b/internal/handler/material_organization_test.go @@ -35,10 +35,8 @@ const ( ) // materialRow 构造单行结果集;不传值表示空结果集(用于"查不到"的路径)。 +// 传值时长度必须和 columns 对齐,列顺序见 materialColumns。 func materialRow(columns []string, values ...driver.Value) *sqlmock.Rows { - if len(columns) == 20 && len(values) == 18 { - values = append(values[:7], append([]driver.Value{nil, nil}, values[7:]...)...) - } rows := sqlmock.NewRows(columns) if len(values) > 0 { rows.AddRow(values...) @@ -97,7 +95,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, nil, nil, "", "", int64(12), nil, "pcs", "active", 1, time.Now(), time.Now())) + int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, 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) { @@ -153,12 +151,12 @@ func TestCreateMaterialUsesHeaderOrganizationInsteadOfBody(t *testing.T) { mock.ExpectQuery(lockCodeRulePattern).WithArgs(int64(1), int64(12)). WillReturnRows(materialRow([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})) mock.ExpectExec(materialInsertPattern). - WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, 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, nil, nil, "", "", int64(12), nil, "个", "active", 1, time.Now(), time.Now())) + int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, nil, nil, nil, nil, "", "", int64(12), nil, "个", "active", 1, time.Now(), time.Now())) mock.ExpectExec(materialLogInsertPattern). WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), sqlmock.AnyArg(), int64(7)). WillReturnResult(sqlmock.NewResult(1, 1)) diff --git a/internal/handler/order_organization_test.go b/internal/handler/order_organization_test.go index 44eeafd2..676f4806 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, nil, nil, "", "", 1, 1, "个", "active", 1, now, now) + AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, nil, nil, nil, nil, "", "", 1, 1, "个", "active", 1, now, now) } // TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。 diff --git a/internal/repository/application.go b/internal/repository/application.go index a6152c90..0e416561 100644 --- a/internal/repository/application.go +++ b/internal/repository/application.go @@ -276,7 +276,7 @@ func (r *ApplicationRepository) CheckApplicationSchema() error { return err } if count == 0 { - return fmt.Errorf("数据库结构缺少 %s.%s:请先执行 scripts/init.sql(空库)或 scripts/upgrades/2026-10-08-company-master-data-isolation.sql(升级)完成建表/升级", c.table, c.column) + return fmt.Errorf("数据库结构缺少 %s.%s:请用 scripts/init.sql 重建数据库(本地库没有增量升级路径)", c.table, c.column) } } return nil diff --git a/internal/repository/company_isolation_generator_test.go b/internal/repository/company_isolation_generator_test.go deleted file mode 100644 index 955aefab..00000000 --- a/internal/repository/company_isolation_generator_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package repository - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// A forgotten regeneration must fail -Check without rewriting files; a source -// rule change must subsequently govern the real self-contained migration. -func isolationGenerator(t *testing.T, args ...string) ([]byte, error) { - t.Helper() - shell, err := exec.LookPath("pwsh") - if err != nil { - shell, err = exec.LookPath("powershell") - } - if err != nil { - t.Skip("PowerShell is required to verify the SQL generation tool") - } - commandArgs := []string{"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", filepath.Join("..", "..", "scripts", "sync-company-integrity.ps1")} - return exec.Command(shell, append(commandArgs, args...)...).CombinedOutput() -} - -func generatedIsolationFixture(t *testing.T) (string, string) { - t.Helper() - if output, err := isolationGenerator(t, "-Check"); err != nil { - t.Fatalf("committed migration must be in sync: %v\n%s", err, output) - } - dir := t.TempDir() - source := filepath.Join(dir, "checks.sql") - migration := filepath.Join(dir, "upgrade.sql") - for _, copy := range []struct{ from, to string }{ - {filepath.Join("..", "..", "scripts", "checks", "company-master-data-integrity.sql"), source}, - {filepath.Join("..", "..", "scripts", "upgrades", "2026-10-08-company-master-data-isolation.sql"), migration}, - } { - content, err := os.ReadFile(copy.from) - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(copy.to, content, 0600); err != nil { - t.Fatal(err) - } - } - return source, migration -} - -func changeIsolationCheckFixture(t *testing.T, source string) { - t.Helper() - content, err := os.ReadFile(source) - if err != nil { - t.Fatal(err) - } - // The fixture adds a literal company-2 blocker. Expectations below are hand - // derived from seedIsolationHistory (material 2 belongs to company 2). - changed := strings.Replace(string(content), "-- END COMPANY INTEGRITY CORE", `INSERT INTO tmp_company_integrity_anomalies -SELECT 'generated_rule',id,'Company two requires business review' FROM materials WHERE organization_id=2; --- END COMPANY INTEGRITY CORE`, 1) - if changed == string(content) { - t.Fatal("canonical check core marker missing") - } - if err = os.WriteFile(source, []byte(changed), 0600); err != nil { - t.Fatal(err) - } -} - -func TestCompanyIsolationIntegrityGeneratorDetectsAndRepairsDrift(t *testing.T) { - source, migration := generatedIsolationFixture(t) - before, err := os.ReadFile(migration) - if err != nil { - t.Fatal(err) - } - changeIsolationCheckFixture(t, source) - output, err := isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration, "-Check") - if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { - t.Fatalf("stale embed must fail -Check with exit 1, got %v\n%s", err, output) - } - after, err := os.ReadFile(migration) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(after, before) { - t.Fatal("-Check mutated stale migration") - } - if output, err = isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration); err != nil { - t.Fatalf("sync changed check: %v\n%s", err, output) - } - once, err := os.ReadFile(migration) - if err != nil { - t.Fatal(err) - } - if output, err = isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration); err != nil { - t.Fatalf("repeat sync: %v\n%s", err, output) - } - twice, err := os.ReadFile(migration) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(once, twice) { - t.Fatal("generation was not deterministic") - } - if output, err = isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration, "-Check"); err != nil { - t.Fatalf("regenerated migration must pass -Check: %v\n%s", err, output) - } -} - -func TestCompanyIsolationGeneratedRuleBlocksActualUpgrade(t *testing.T) { - db := isolationDB(t, true) - seedIsolationHistory(t, db) - source, migration := generatedIsolationFixture(t) - changeIsolationCheckFixture(t, source) - if output, err := isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration); err != nil { - t.Fatalf("generate fixture: %v\n%s", err, output) - } - if err := runIsolationSQL(db, source); err != nil { - t.Fatal(err) - } - var count int - if err := db.QueryRow(`SELECT COUNT(*) FROM tmp_company_integrity_anomalies WHERE issue='generated_rule' AND entity_id=2`).Scan(&count); err != nil || count != 1 { - t.Fatalf("standalone fixture rule=%d err=%v", count, err) - } - if err := runIsolationSQL(db, migration); err == nil || !strings.Contains(err.Error(), "integrity anomalies") { - t.Fatalf("generated rule must block actual upgrade, got %v", err) - } - if err := db.QueryRow(`SELECT COUNT(*) FROM tmp_company_integrity_anomalies WHERE issue='generated_rule' AND entity_id=2`).Scan(&count); err != nil || count != 1 { - t.Fatalf("embedded fixture rule=%d err=%v", count, err) - } - if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='stock_logs' AND column_name='organization_id'`).Scan(&count); err != nil || count != 0 { - t.Fatalf("generated blocker changed business schema: %d %v", count, err) - } -} diff --git a/internal/repository/material_import_test.go b/internal/repository/material_import_test.go index 7550d559..a7c9c102 100644 --- a/internal/repository/material_import_test.go +++ b/internal/repository/material_import_test.go @@ -48,8 +48,8 @@ func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) { // 两行折叠成一条多值 INSERT mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)). WithArgs( - int64(1), "A010202", "螺栓", nil, "", nil, nil, nil, nil, int64(12), nil, "个", "active", 1, - int64(1), "A010203", "螺母", nil, "", nil, nil, nil, nil, int64(12), nil, "个", "active", 1, + int64(1), "A010202", "螺栓", nil, "", nil, nil, nil, nil, nil, int64(12), nil, "个", "active", 1, + int64(1), "A010203", "螺母", nil, "", nil, 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/service/material_import_excel_test.go b/internal/service/material_import_excel_test.go index d3814f36..c6ea846a 100644 --- a/internal/service/material_import_excel_test.go +++ b/internal/service/material_import_excel_test.go @@ -234,7 +234,9 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) { if err != nil { t.Fatalf("read rows: %v", err) } - if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][6] != "小类" { + // 表头列数以 materialImportHeaders 为准:写死下标会在增删列时静默指向别的列。 + if len(headers) == 0 || len(headers[0]) != len(materialImportHeaders) || + headers[0][0] != "物料名称" || headers[0][len(headers[0])-1] != "小类" { t.Fatalf("表头 = %v", headers) } // 模板必须能被自己的解析器接受(表头一致) @@ -254,11 +256,14 @@ func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte { // buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。 func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte { t.Helper() - // 旧的五列测试数据为「名称、规格、单位、大类、小类」;补上新增的两个选填列。 + // 测试数据用五列简写「名称、规格、单位、大类、小类」,其余列补空。 + // 补位的顺序必须与 materialImportHeaders 一致(物料名称、规格、单位、供应商、 + // 制造商、来源、备注、大类、小类):表头改列时这里要同步改,否则大类小类会被 + // 静默解析成来源和备注,测试却只是「结果对不上」。 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]} + dataRows[i] = []string{row[0], row[1], row[2], "", "", "", "", row[3], row[4]} } } } diff --git a/internal/service/material_test.go b/internal/service/material_test.go index a4257b92..1bd271a2 100644 --- a/internal/service/material_test.go +++ b/internal/service/material_test.go @@ -296,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, 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 f13a9cbe..f89a7e00 100644 --- a/internal/service/outbound_test.go +++ b/internal/service/outbound_test.go @@ -26,8 +26,8 @@ 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", "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()) + return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "source", "remark", "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, nil, nil, "", "", 1, 1, "pc", "active", 1, time.Now(), time.Now()) } func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) { diff --git a/scripts/sync-company-integrity.ps1 b/scripts/sync-company-integrity.ps1 deleted file mode 100644 index 353b1e49..00000000 --- a/scripts/sync-company-integrity.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -# The standalone check is canonical; the upgrade stays self-contained for mysql SOURCE. -# Run from any directory. Use -Check in verification to reject an outdated embed. -[CmdletBinding()] -param( - [string]$SourcePath, - [string]$MigrationPath, - [switch]$Check -) - -if (-not $SourcePath) { $SourcePath = Join-Path $PSScriptRoot 'checks/company-master-data-integrity.sql' } -if (-not $MigrationPath) { $MigrationPath = Join-Path $PSScriptRoot 'upgrades/2026-10-08-company-master-data-isolation.sql' } - -$ErrorActionPreference = 'Stop' - -function Get-MarkedContent { - param([string]$Text, [string]$BeginMarker, [string]$EndMarker) - - $begin = $Text.IndexOf($BeginMarker, [System.StringComparison]::Ordinal) - $end = $Text.IndexOf($EndMarker, [System.StringComparison]::Ordinal) - if ($begin -lt 0 -or $end -le $begin -or - $Text.IndexOf($BeginMarker, $begin + $BeginMarker.Length, [System.StringComparison]::Ordinal) -ge 0 -or - $Text.IndexOf($EndMarker, $end + $EndMarker.Length, [System.StringComparison]::Ordinal) -ge 0) { - throw "Expected one ordered marker pair: $BeginMarker / $EndMarker" - } - $contentStart = $begin + $BeginMarker.Length - return @{ - Start = $contentStart - End = $end - Content = $Text.Substring($contentStart, $end - $contentStart) - } -} - -try { - $source = (Resolve-Path -LiteralPath $SourcePath).Path - $migration = (Resolve-Path -LiteralPath $MigrationPath).Path - if ($source -eq $migration) { throw 'Source and migration must be different files' } - - # Normalize only line endings: content, statement order and whitespace in the - # canonical block otherwise remain exact. Output is deterministic UTF-8/LF. - $sourceText = [System.IO.File]::ReadAllText($source).Replace("`r`n", "`n").Replace("`r", "`n") - $migrationText = [System.IO.File]::ReadAllText($migration).Replace("`r`n", "`n").Replace("`r", "`n") - $core = Get-MarkedContent $sourceText '-- BEGIN COMPANY INTEGRITY CORE' '-- END COMPANY INTEGRITY CORE' - $embedded = Get-MarkedContent $migrationText '-- BEGIN GENERATED COMPANY INTEGRITY CHECKS' '-- END GENERATED COMPANY INTEGRITY CHECKS' - $expected = $migrationText.Substring(0, $embedded.Start) + $core.Content + $migrationText.Substring($embedded.End) - - if ($Check) { - if ($migrationText -cne $expected) { - Write-Error 'Integrity SQL embed is stale. Run scripts/sync-company-integrity.ps1 to regenerate it.' -ErrorAction Continue - exit 1 - } - Write-Output 'Integrity SQL embed is current.' - exit 0 - } - - [System.IO.File]::WriteAllText($migration, $expected, [System.Text.UTF8Encoding]::new($false)) - Write-Output 'Integrity SQL embed regenerated from the canonical check.' - exit 0 -} catch { - Write-Error $_ -ErrorAction Continue - exit 1 -}