From 7e56d462b3cdcce0d02a17acfee282dedfc0e629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Thu, 8 Oct 2026 17:04:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E4=B8=8E=E5=AE=A1=E8=AE=A1=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/organization.go | 20 +- .../company_isolation_generator_test.go | 133 ++++++ .../company_isolation_integration_test.go | 452 ++++++++++++++++++ internal/repository/organization_access.go | 48 ++ .../repository/organization_access_test.go | 186 +++++++ internal/repository/organization_audit.go | 73 +++ .../testdata/company-isolation-legacy.sql | 40 ++ .../checks/company-master-data-integrity.sql | 122 +++++ scripts/init.sql | 35 +- scripts/sync-company-integrity.ps1 | 58 +++ ...26-10-08-company-master-data-isolation.sql | 224 +++++++++ 11 files changed, 1383 insertions(+), 8 deletions(-) create mode 100644 internal/repository/company_isolation_generator_test.go create mode 100644 internal/repository/company_isolation_integration_test.go create mode 100644 internal/repository/organization_access.go create mode 100644 internal/repository/organization_access_test.go create mode 100644 internal/repository/organization_audit.go create mode 100644 internal/repository/testdata/company-isolation-legacy.sql create mode 100644 scripts/checks/company-master-data-integrity.sql create mode 100644 scripts/sync-company-integrity.ps1 create mode 100644 scripts/upgrades/2026-10-08-company-master-data-isolation.sql diff --git a/internal/model/organization.go b/internal/model/organization.go index 9830a931..4b23885e 100644 --- a/internal/model/organization.go +++ b/internal/model/organization.go @@ -1,6 +1,24 @@ package model -import "time" +import ( + "encoding/json" + "time" +) + +type OrganizationAccess struct { + Organizations []Organization `json:"organizations"` + DefaultOrganizationID *int64 `json:"default_organization_id"` +} + +type OrganizationAuditLog struct { + ID int64 `json:"id"` + ActorUserID int64 `json:"actor_user_id"` + OrganizationID *int64 `json:"organization_id"` + Action string `json:"action"` + OldValue json.RawMessage `json:"old_value"` + NewValue json.RawMessage `json:"new_value"` + CreatedAt time.Time `json:"created_at"` +} type Organization struct { ID int64 `json:"id"` diff --git a/internal/repository/company_isolation_generator_test.go b/internal/repository/company_isolation_generator_test.go new file mode 100644 index 00000000..955aefab --- /dev/null +++ b/internal/repository/company_isolation_generator_test.go @@ -0,0 +1,133 @@ +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/company_isolation_integration_test.go b/internal/repository/company_isolation_integration_test.go new file mode 100644 index 00000000..e61920f3 --- /dev/null +++ b/internal/repository/company_isolation_integration_test.go @@ -0,0 +1,452 @@ +package repository + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/wmms/wmms/pkg/database" +) + +// Each test creates its own disposable schema on the explicitly configured server. +func isolationDB(t *testing.T, legacy bool) *sql.DB { + t.Helper() + dsn := os.Getenv("TEST_DB_DSN") + if dsn == "" { + t.Skip("real MySQL integration requires TEST_DB_DSN") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(cfg.DBName, "company_isolation_test") { + t.Fatal("TEST_DB_DSN must name a company_isolation_test schema") + } + cfg.DBName = "" + cfg.MultiStatements = true + cfg.ParseTime = true + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + name := fmt.Sprintf("company_isolation_test_%d", time.Now().UnixNano()) + if _, err = admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + admin.Close() + t.Fatal(err) + } + cfg.DBName = name + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + original := database.DB + database.DB = db + t.Cleanup(func() { database.DB = original; db.Close(); admin.Exec("DROP DATABASE `" + name + "`"); admin.Close() }) + path := filepath.Join("..", "..", "scripts", "init.sql") + if legacy { + path = filepath.Join("testdata", "company-isolation-legacy.sql") + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var lines []string + for _, line := range strings.Split(strings.TrimPrefix(string(content), "\ufeff"), "\n") { + if strings.HasPrefix(line, "CREATE DATABASE") || strings.HasPrefix(line, "USE ") { + continue + } + lines = append(lines, line) + } + if _, err = db.Exec(strings.Join(lines, "\n")); err != nil { + t.Fatalf("initialize %s: %v", path, err) + } + return db +} + +func mustSQL(t *testing.T, db *sql.DB, query string, args ...interface{}) { + t.Helper() + if _, err := db.Exec(query, args...); err != nil { + t.Fatalf("fixture SQL: %v\n%s", err, query) + } +} + +// DELIMITER is a mysql-client directive, not server SQL. Execute the same file's +// statements with its declared delimiter, including the full stored procedure. +func runIsolationSQL(db *sql.DB, path string) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + delimiter := ";" + var statement strings.Builder + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "DELIMITER ") { + delimiter = strings.TrimSpace(strings.TrimPrefix(trimmed, "DELIMITER ")) + continue + } + if strings.HasPrefix(trimmed, "--") || trimmed == "" { + continue + } + statement.WriteString(line) + statement.WriteByte('\n') + accumulated := strings.TrimSpace(statement.String()) + if strings.HasSuffix(accumulated, delimiter) { + accumulated = strings.TrimSuffix(accumulated, delimiter) + if _, err = db.Exec(accumulated); err != nil { + return err + } + statement.Reset() + } + } + if strings.TrimSpace(statement.String()) != "" { + return fmt.Errorf("unterminated SQL in %s", path) + } + return nil +} + +func upgradeIsolation(db *sql.DB) error { + return runIsolationSQL(db, filepath.Join("..", "..", "scripts", "upgrades", "2026-10-08-company-master-data-isolation.sql")) +} + +func seedIsolationHistory(t *testing.T, db *sql.DB) { + mustSQL(t, db, `INSERT INTO organizations(id,code,name) VALUES (1,'ONE','One'),(2,'TWO','Two'); + INSERT INTO users(id,username,password) VALUES (1,'ordinary','x'),(2,'admin','x'); + INSERT INTO roles(id,code,name) VALUES(1,'super_admin','Admin'); INSERT INTO user_roles VALUES(2,1,NOW()); + INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(1,2,1); + INSERT INTO material_categories(id,organization_id,code,name) VALUES(1,1,'C','C'),(2,2,'C','C'); + INSERT INTO units(id,organization_id,code,name) VALUES(1,1,'U','U'),(2,2,'U','U'); + INSERT INTO materials(id,organization_id,material_code,name,category_id,base_unit_id) VALUES(1,1,'M','M',1,1),(2,2,'M','M',2,2); + INSERT INTO warehouses(id,organization_id,code,name) VALUES(1,1,'W','W'),(2,2,'W','W'); + INSERT INTO warehouse_locations(id,warehouse_id,code,name) VALUES(1,1,'L','L'),(2,2,'L','L'); + INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand,reserved,locked,in_transit) VALUES(2,2,2,2,8,1,2,3); + INSERT INTO inbound_orders(id,organization_id,warehouse_id,location_id,inbound_date,handler) VALUES(1,2,2,2,NOW(),'H'); + INSERT INTO inbound_items(inbound_order_id,material_id,quantity) VALUES(1,2,10); + INSERT INTO outbound_orders(id,organization_id,warehouse_id,location_id,outbound_date,handler,receiver) VALUES(1,2,2,2,NOW(),'H','R'); + INSERT INTO outbound_items(outbound_order_id,material_id,quantity) VALUES(1,2,2); + INSERT INTO material_applications(id,user_id) VALUES(1,1); + INSERT INTO application_items(application_id,material_id,quantity) VALUES(1,2,2); + INSERT INTO stock_logs(material_id,type,quantity,related_order_type,related_order_id) VALUES(2,'in',10,'inbound',1),(2,'out',2,'outbound',1);`) +} + +func assertIsolationConstraints(t *testing.T, db *sql.DB) { + t.Helper() + mustSQL(t, db, `INSERT INTO organizations(id,code,name) VALUES(10,'TEN','Ten'),(11,'ELEVEN','Eleven'),(12,'TWELVE','Twelve'); + INSERT INTO users(id,username,password) VALUES(10,'constraint','x'); + INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(10,10,1);`) + if _, err := db.Exec(`INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(10,11,1)`); err == nil { + t.Fatal("second default organization was accepted") + } + mustSQL(t, db, `INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(10,11,0),(10,12,0)`) + mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES(11,'default-race','x')`) + var defaultWG sync.WaitGroup + defaultStart := make(chan struct{}) + defaultResults := make(chan error, 2) + for _, organizationID := range []int64{10, 11} { + defaultWG.Add(1) + go func(id int64) { + defer defaultWG.Done() + <-defaultStart + _, err := db.Exec(`INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(11,?,1)`, id) + defaultResults <- err + }(organizationID) + } + close(defaultStart) + defaultWG.Wait() + close(defaultResults) + defaultSuccesses := 0 + for err := range defaultResults { + if err == nil { + defaultSuccesses++ + } else if me, ok := err.(*mysql.MySQLError); !ok || me.Number != 1062 { + t.Fatalf("unexpected default race error: %v", err) + } + } + if defaultSuccesses != 1 { + t.Fatalf("concurrent default successes=%d, want 1", defaultSuccesses) + } + mustSQL(t, db, `INSERT INTO warehouses(id,organization_id,code,name,status) VALUES(10,10,'W','W','inactive'),(11,11,'W','W','active'); + INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES(10,10,'L','L','inactive'),(11,11,'L','L','active');`) + for _, query := range []string{`INSERT INTO warehouses(organization_id,code,name) VALUES(10,'SECOND','Second')`, `INSERT INTO warehouse_locations(warehouse_id,code,name) VALUES(10,'SECOND','Second')`} { + if _, err := db.Exec(query); err == nil { + t.Fatalf("unique dimension bypassed: %s", query) + } + } + // Race two independent connections; only one creation may commit. + for _, query := range []string{`INSERT INTO warehouses(organization_id,code,name) VALUES(12,?,?)`, `INSERT INTO warehouse_locations(warehouse_id,code,name) SELECT id,?,? FROM warehouses WHERE organization_id=12`} { + var wg sync.WaitGroup + start := make(chan struct{}) + results := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + _, err := db.Exec(query, fmt.Sprint(i), fmt.Sprint(i)) + results <- err + }(i) + } + close(start) + wg.Wait() + close(results) + successes := 0 + for err := range results { + if err == nil { + successes++ + } else if me, ok := err.(*mysql.MySQLError); !ok || me.Number != 1062 { + t.Fatalf("unexpected race failure: %v", err) + } + } + if successes != 1 { + t.Fatalf("concurrent creation successes=%d, want 1", successes) + } + } +} + +func TestCompanyIsolationFreshInit(t *testing.T) { + db := isolationDB(t, false) + assertIsolationConstraints(t, db) + assertIsolationRequiredDimensions(t, db) + // Explicit dimensions are mandatory; absence must fail rather than company 1. + for _, query := range []string{`INSERT INTO inbound_orders(inbound_date,handler) VALUES(NOW(),'H')`, `INSERT INTO outbound_orders(outbound_date,handler,receiver) VALUES(NOW(),'H','R')`, `INSERT INTO material_applications(user_id) VALUES(10)`, `INSERT INTO stock_logs(material_id,type,quantity,related_order_type,related_order_id) VALUES(1,'in',1,'inbound',1)`} { + _, err := db.Exec(query) + if me, ok := err.(*mysql.MySQLError); !ok || me.Number != 1364 { + t.Fatalf("missing dimensions must fail with no default (1364), got %v: %s", err, query) + } + } + if err := upgradeIsolation(db); err == nil { + t.Fatal("fresh schema must already record the migration version") + } +} + +func assertIsolationRequiredDimensions(t *testing.T, db *sql.DB) { + t.Helper() + for _, tc := range []struct { + table string + columns []string + }{ + {"inbound_orders", []string{"organization_id", "warehouse_id", "location_id"}}, + {"outbound_orders", []string{"organization_id", "warehouse_id", "location_id"}}, + {"stock_logs", []string{"organization_id", "warehouse_id", "location_id"}}, + {"material_applications", []string{"organization_id"}}, + } { + for _, column := range tc.columns { + var nullable string + var defaultValue sql.NullString + if err := db.QueryRow(`SELECT is_nullable,column_default FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`, tc.table, column).Scan(&nullable, &defaultValue); err != nil { + t.Fatal(err) + } + if nullable != "NO" || defaultValue.Valid { + t.Fatalf("%s.%s nullable=%s default=%v", tc.table, column, nullable, defaultValue) + } + } + } +} + +// Schema parity covers real column types/null/default/generated expressions, +// index column order/uniqueness, and foreign keys, ignoring names/order of FKs. +func isolationSchema(t *testing.T, db *sql.DB) []string { + t.Helper() + var signature []string + for _, query := range []string{ + `SELECT CONCAT('column:',table_name,':',column_name,':',column_type,':',is_nullable,':',COALESCE(column_default,''),':',extra,':',generation_expression) FROM information_schema.columns WHERE table_schema=DATABASE() ORDER BY table_name,column_name`, + `SELECT CONCAT('index:',table_name,':',index_name,':',non_unique,':',GROUP_CONCAT(column_name ORDER BY seq_in_index)) FROM information_schema.statistics WHERE table_schema=DATABASE() GROUP BY table_name,index_name,non_unique ORDER BY table_name,index_name`, + `SELECT CONCAT('fk:',table_name,':',column_name,':',referenced_table_name,':',referenced_column_name) FROM information_schema.key_column_usage WHERE table_schema=DATABASE() AND referenced_table_name IS NOT NULL ORDER BY table_name,column_name`, + } { + rows, err := db.Query(query) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + rows.Close() + t.Fatal(err) + } + signature = append(signature, value) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + rows.Close() + } + return signature +} + +func TestCompanyIsolationFreshAndUpgradedSchemaAgree(t *testing.T) { + fresh := isolationDB(t, false) + want := isolationSchema(t, fresh) + upgraded := isolationDB(t, true) + seedIsolationHistory(t, upgraded) + if err := upgradeIsolation(upgraded); err != nil { + t.Fatal(err) + } + got := isolationSchema(t, upgraded) + if !reflect.DeepEqual(got, want) { + wanted := map[string]bool{} + actual := map[string]bool{} + for _, v := range want { + wanted[v] = true + } + for _, v := range got { + actual[v] = true + } + for _, v := range got { + if !wanted[v] { + t.Logf("upgrade only: %s", v) + } + } + for _, v := range want { + if !actual[v] { + t.Logf("fresh only: %s", v) + } + } + t.Fatal("fresh and upgraded schema differ") + } +} + +func TestCompanyIsolationApplicationLogBackfill(t *testing.T) { + db := isolationDB(t, true) + seedIsolationHistory(t, db) + mustSQL(t, db, `UPDATE material_applications SET status='approved' WHERE id=1; UPDATE stock_logs SET related_order_type='application' WHERE id=2`) + if err := upgradeIsolation(db); err != nil { + t.Fatal(err) + } + assertIsolationRequiredDimensions(t, db) + var org, warehouse, location int64 + if err := db.QueryRow(`SELECT organization_id,warehouse_id,location_id FROM stock_logs WHERE id=2`).Scan(&org, &warehouse, &location); err != nil { + t.Fatal(err) + } + if org != 2 || warehouse != 2 || location != 2 { + t.Fatalf("application log=%d/%d/%d, want 2/2/2", org, warehouse, location) + } + // After upgrading, checks must also reject stored dimensions that disagree + // with the verified source document, even if each standalone FK is valid. + mustSQL(t, db, `UPDATE stock_logs SET organization_id=1 WHERE id=2`) + if err := runIsolationSQL(db, filepath.Join("..", "..", "scripts", "checks", "company-master-data-integrity.sql")); err != nil { + t.Fatal(err) + } + var anomalies int + if err := db.QueryRow(`SELECT COUNT(*) FROM tmp_company_integrity_anomalies WHERE issue='stored_stock_log_dimensions' AND entity_id=2`).Scan(&anomalies); err != nil || anomalies != 1 { + t.Fatalf("stored dimension anomaly=%d err=%v", anomalies, err) + } + mustSQL(t, db, `UPDATE material_applications SET organization_id=1 WHERE id=1`) + if err := runIsolationSQL(db, filepath.Join("..", "..", "scripts", "checks", "company-master-data-integrity.sql")); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM tmp_company_integrity_anomalies WHERE issue='stored_application_dimensions' AND entity_id=1`).Scan(&anomalies); err != nil || anomalies != 1 { + t.Fatalf("stored application anomaly=%d err=%v", anomalies, err) + } +} + +func TestCompanyIsolationApplicationLogsRejectUnverifiedSources(t *testing.T) { + for _, tc := range []struct{ name, query string }{ + {"unapproved", `UPDATE stock_logs SET related_order_type='application' WHERE id=2`}, + {"quantity mismatch", `UPDATE material_applications SET status='approved' WHERE id=1; UPDATE stock_logs SET related_order_type='application',quantity=999 WHERE id=2`}, + {"missing inventory", `UPDATE material_applications SET status='approved' WHERE id=1; UPDATE stock_logs SET related_order_type='application' WHERE id=2; DELETE FROM inventory_balances`}, + } { + t.Run(tc.name, func(t *testing.T) { + db := isolationDB(t, true) + seedIsolationHistory(t, db) + mustSQL(t, db, tc.query) + err := upgradeIsolation(db) + if err == nil || !strings.Contains(err.Error(), "integrity anomalies") { + t.Fatalf("unverified source accepted: %v", err) + } + }) + } +} + +func TestCompanyIsolationLegacyUpgrade(t *testing.T) { + db := isolationDB(t, true) + seedIsolationHistory(t, db) + if err := upgradeIsolation(db); err != nil { + t.Fatal(err) + } + var org, warehouse, location int64 + if err := db.QueryRow(`SELECT organization_id,warehouse_id,location_id FROM stock_logs WHERE id=1`).Scan(&org, &warehouse, &location); err != nil { + t.Fatal(err) + } + if org != 2 || warehouse != 2 || location != 2 { + t.Fatalf("log dimensions=%d/%d/%d, want 2/2/2", org, warehouse, location) + } + if err := db.QueryRow(`SELECT organization_id FROM material_applications WHERE id=1`).Scan(&org); err != nil || org != 2 { + t.Fatalf("application org=%d err=%v", org, err) + } + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migration_reconciliations WHERE version='2026-10-08-company-master-data-isolation' AND phase IN ('before','after')`).Scan(&count); err != nil || count != 4 { + t.Fatalf("snapshots=%d err=%v", count, err) + } + var onHand string + if err := db.QueryRow(`SELECT on_hand FROM schema_migration_reconciliations WHERE organization_id=2 AND phase='after'`).Scan(&onHand); err != nil || onHand != "8.000000" { + t.Fatalf("stock reconciliation=%s err=%v", onHand, err) + } + for _, phase := range []string{"before", "after"} { + var reserved, locked, inTransit string + var inbound, outbound, applications, logs int64 + if err := db.QueryRow(`SELECT reserved,locked,in_transit,inbound_count,outbound_count,application_count,stock_log_count FROM schema_migration_reconciliations WHERE organization_id=2 AND phase=?`, phase).Scan(&reserved, &locked, &inTransit, &inbound, &outbound, &applications, &logs); err != nil { + t.Fatal(err) + } + if reserved != "1.000000" || locked != "2.000000" || inTransit != "3.000000" || inbound != 1 || outbound != 1 || applications != 1 || logs != 2 { + t.Fatalf("%s reconciliation=%s/%s/%s/%d/%d/%d/%d", phase, reserved, locked, inTransit, inbound, outbound, applications, logs) + } + } + var status, version string + if err := db.QueryRow(`SELECT status,JSON_UNQUOTE(JSON_EXTRACT(details,'$.version')) FROM schema_migrations WHERE version='2026-10-08-company-master-data-isolation'`).Scan(&status, &version); err != nil || status != "completed" || version != "2026-10-08-company-master-data-isolation" { + t.Fatalf("version record=%s/%s err=%v", status, version, err) + } + if err := upgradeIsolation(db); err == nil { + t.Fatal("migration rerun accepted") + } + for _, state := range []string{"running", "failed"} { + mustSQL(t, db, `UPDATE schema_migrations SET status=? WHERE version='2026-10-08-company-master-data-isolation'`, state) + if err := upgradeIsolation(db); err == nil || !strings.Contains(err.Error(), "version already recorded") { + t.Fatalf("%s version was rerun: %v", state, err) + } + } + assertIsolationConstraints(t, db) +} + +func TestCompanyIsolationInvalidHistoryBlocksUpgrade(t *testing.T) { + cases := []struct{ name, query string }{ + {"duplicate defaults", `INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(1,1,1)`}, + {"multiple warehouses", `INSERT INTO warehouses(organization_id,code,name) VALUES(2,'SECOND','Second')`}, + {"multiple locations", `INSERT INTO warehouse_locations(warehouse_id,code,name) VALUES(2,'SECOND','Second')`}, + {"cross-company parent", `UPDATE material_categories SET parent_id=1 WHERE id=2`}, + {"cross-company category", `UPDATE materials SET category_id=1 WHERE id=2`}, + {"cross-company unit", `UPDATE materials SET base_unit_id=1 WHERE id=2`}, + {"rule category", `INSERT INTO code_rules(organization_id,category_id,prefix,sequence_length) VALUES(2,1,'X',4)`}, + {"no effective grant", `DELETE FROM user_organizations WHERE user_id=1`}, + {"empty application", `DELETE FROM application_items WHERE application_id=1`}, + {"mixed application", `INSERT INTO application_items(application_id,material_id,quantity) VALUES(1,1,1)`}, + {"orphan log", `UPDATE stock_logs SET related_order_id=999 WHERE id=1`}, + {"wrong log material", `UPDATE stock_logs SET material_id=1 WHERE id=1`}, + {"unknown log type", `UPDATE stock_logs SET related_order_type='unknown' WHERE id=1`}, + {"balance dimensions", `UPDATE inventory_balances SET warehouse_id=1 WHERE material_id=2`}, + {"warning dimensions", `INSERT INTO stock_warning_rules(organization_id,warehouse_id,material_id) VALUES(2,1,2)`}, + {"order dimensions", `UPDATE inbound_orders SET location_id=1 WHERE id=1`}, + {"order material", `UPDATE inbound_items SET material_id=1 WHERE inbound_order_id=1`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db := isolationDB(t, true) + seedIsolationHistory(t, db) + mustSQL(t, db, tc.query) + err := upgradeIsolation(db) + if err == nil || !strings.Contains(err.Error(), "integrity anomalies") { + t.Fatalf("invalid history must block with integrity error, got %v", err) + } + var count int + 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("blocked upgrade changed business schema: %d %v", count, err) + } + }) + } +} diff --git a/internal/repository/organization_access.go b/internal/repository/organization_access.go new file mode 100644 index 00000000..8b7d94fe --- /dev/null +++ b/internal/repository/organization_access.go @@ -0,0 +1,48 @@ +package repository + +import ( + "database/sql" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" +) + +// Authorization is evaluated from current database grants and enabled roles on +// every call, so role changes and grant revocation do not wait for JWT expiry. +func (r *OrganizationRepository) ListAccessible(userID int64) (*model.OrganizationAccess, error) { + rows, err := database.DB.Query(`SELECT o.id,o.code,o.name,o.status,o.default_timezone,o.created_at,o.updated_at, + COALESCE(g.is_default,0) FROM organizations o + LEFT JOIN user_organizations g ON g.organization_id=o.id AND g.user_id=? + WHERE o.status='active' AND (g.user_id IS NOT NULL OR EXISTS( + SELECT 1 FROM user_roles ur JOIN roles r ON r.id=ur.role_id + WHERE ur.user_id=? AND r.code='super_admin' AND r.enabled=1)) ORDER BY o.id`, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + access := &model.OrganizationAccess{Organizations: make([]model.Organization, 0)} + for rows.Next() { + var item model.Organization + var isDefault bool + if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Status, &item.DefaultTimezone, &item.CreatedAt, &item.UpdatedAt, &isDefault); err != nil { + return nil, err + } + access.Organizations = append(access.Organizations, item) + if isDefault { + id := item.ID + access.DefaultOrganizationID = &id + } + } + return access, rows.Err() +} + +func (r *OrganizationRepository) CanAccess(userID, organizationID int64) (bool, error) { + var allowed bool + err := database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM organizations o WHERE o.id=? AND o.status='active' + AND (EXISTS(SELECT 1 FROM user_organizations g WHERE g.user_id=? AND g.organization_id=o.id) + OR EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON r.id=ur.role_id + WHERE ur.user_id=? AND r.code='super_admin' AND r.enabled=1)))`, organizationID, userID, userID).Scan(&allowed) + if err == sql.ErrNoRows { + return false, nil + } + return allowed, err +} diff --git a/internal/repository/organization_access_test.go b/internal/repository/organization_access_test.go new file mode 100644 index 00000000..69aeed2d --- /dev/null +++ b/internal/repository/organization_access_test.go @@ -0,0 +1,186 @@ +package repository + +import ( + "encoding/json" + "testing" + + "github.com/wmms/wmms/internal/model" +) + +// Removing the role enabled predicate, trusting a previous login role, or +// omitting the organization status filter must expose unauthorized companies. +func TestOrganizationAccessUsesCurrentEnabledRolesAndGrants(t *testing.T) { + db := isolationDB(t, false) + mustSQL(t, db, `INSERT INTO organizations(id,code,name,status) VALUES(2,'TWO','Two','active'),(3,'THREE','Three','inactive'); + INSERT INTO users(id,username,password) VALUES(1,'ordinary','x'),(2,'admin','x'),(3,'disabled-admin','x'),(4,'no-grants','x'); + INSERT INTO user_roles(user_id,role_id) SELECT 2,id FROM roles WHERE code='super_admin'; + INSERT INTO user_roles(user_id,role_id) SELECT 3,id FROM roles WHERE code='super_admin'; + INSERT INTO roles(id,code,name,enabled) VALUES(20,'old_admin','Old',0); + INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(1,2,1),(1,3,0),(3,2,1);`) + repo := NewOrganizationRepository() + access, err := repo.ListAccessible(1) + if err != nil { + t.Fatal(err) + } + if len(access.Organizations) != 1 || access.Organizations[0].ID != 2 || access.DefaultOrganizationID == nil || *access.DefaultOrganizationID != 2 { + t.Fatalf("ordinary access=%+v", access) + } + allowed, err := repo.CanAccess(1, 1) + if err != nil || allowed { + t.Fatalf("ungranted organization allowed=%v err=%v", allowed, err) + } + allowed, err = repo.CanAccess(1, 3) + if err != nil || allowed { + t.Fatalf("inactive organization allowed=%v err=%v", allowed, err) + } + access, err = repo.ListAccessible(2) + if err != nil || len(access.Organizations) != 2 || access.DefaultOrganizationID != nil { + t.Fatalf("ungranted admin access=%+v err=%v", access, err) + } + allowed, err = repo.CanAccess(2, 2) + if err != nil || !allowed { + t.Fatalf("enabled admin access=%v err=%v", allowed, err) + } + mustSQL(t, db, `DELETE FROM user_organizations WHERE user_id=1`) + access, err = repo.ListAccessible(1) + if err != nil || len(access.Organizations) != 0 || access.DefaultOrganizationID != nil { + t.Fatalf("revoked grant access=%+v err=%v", access, err) + } + allowed, err = repo.CanAccess(1, 2) + if err != nil || allowed { + t.Fatalf("revoked grant allowed=%v err=%v", allowed, err) + } + mustSQL(t, db, `UPDATE roles SET enabled=0 WHERE code='super_admin'`) + access, err = repo.ListAccessible(2) + if err != nil || len(access.Organizations) != 0 { + t.Fatalf("disabled admin access=%+v err=%v", access, err) + } + allowed, err = repo.CanAccess(2, 2) + if err != nil || allowed { + t.Fatalf("disabled admin allowed=%v err=%v", allowed, err) + } + access, err = repo.ListAccessible(3) + if err != nil || len(access.Organizations) != 1 || access.Organizations[0].ID != 2 { + t.Fatalf("ordinary grant with disabled role access=%+v err=%v", access, err) + } + allowed, err = repo.CanAccess(999, 2) + if err != nil || allowed { + t.Fatalf("nonexistent user allowed=%v err=%v", allowed, err) + } +} + +// An audit failure must reach the caller so its business mutation can roll back; +// a rollback must remove both records, and reads must require an organization. +func TestOrganizationAuditTransactionAndScope(t *testing.T) { + db := isolationDB(t, false) + mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES(1,'actor','x'); INSERT INTO organizations(id,code,name) VALUES(2,'TWO','Two')`) + repo := NewOrganizationAuditRepository() + org1, org2 := int64(1), int64(2) + entry := &model.OrganizationAuditLog{ActorUserID: 1, OrganizationID: &org1, Action: "organization.switch", OldValue: json.RawMessage(`{"organization_id":2}`), NewValue: json.RawMessage(`{"organization_id":1}`)} + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + mustSQL(t, db, `UPDATE organizations SET name='Before' WHERE id=1`) + if _, err = tx.Exec(`UPDATE organizations SET name='Rolled back' WHERE id=1`); err != nil { + t.Fatal(err) + } + if err = repo.CreateTx(tx, entry); err != nil { + t.Fatal(err) + } + if err = tx.Rollback(); err != nil { + t.Fatal(err) + } + logs, err := repo.ListByOrganization(1, 20, 0) + if err != nil || len(logs) != 0 { + t.Fatalf("rolled back audit=%+v err=%v", logs, err) + } + var organizationName string + if err = db.QueryRow(`SELECT name FROM organizations WHERE id=1`).Scan(&organizationName); err != nil || organizationName != "Before" { + t.Fatalf("business rollback=%s err=%v", organizationName, err) + } + tx, err = db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + if _, err = tx.Exec(`UPDATE organizations SET name='Audit failed' WHERE id=1`); err != nil { + t.Fatal(err) + } + entry.NewValue = json.RawMessage(`not-json`) + if err = repo.CreateTx(tx, entry); err == nil { + t.Fatal("invalid audit JSON was accepted") + } + tx.Rollback() + if err = db.QueryRow(`SELECT name FROM organizations WHERE id=1`).Scan(&organizationName); err != nil || organizationName != "Before" { + t.Fatalf("audit failure business rollback=%s err=%v", organizationName, err) + } + entry.NewValue = json.RawMessage(`{"organization_id":1}`) + tx, err = db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + if _, err = tx.Exec(`UPDATE organizations SET name='Committed' WHERE id=1`); err != nil { + t.Fatal(err) + } + if err = repo.CreateTx(tx, entry); err != nil { + t.Fatal(err) + } + if err = tx.Commit(); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`SELECT name FROM organizations WHERE id=1`).Scan(&organizationName); err != nil || organizationName != "Committed" { + t.Fatalf("business commit=%s err=%v", organizationName, err) + } + entry.OrganizationID = &org2 + entry.Action = "organization.grants.update" + entry.OldValue = json.RawMessage(`{"organization_ids":[1],"default_organization_id":1}`) + entry.NewValue = json.RawMessage(`{"organization_ids":[2],"default_organization_id":2}`) + if err = repo.Create(entry); err != nil { + t.Fatal(err) + } + entry.OrganizationID = nil + entry.Action = "organization.grants.update" + if err = repo.Create(entry); err != nil { + t.Fatal(err) + } + logs, err = repo.ListByOrganization(1, 20, 0) + if err != nil || len(logs) != 1 || logs[0].Action != "organization.switch" { + t.Fatalf("scoped audit=%+v err=%v", logs, err) + } + var newValue struct { + OrganizationID int64 `json:"organization_id"` + } + if err = json.Unmarshal(logs[0].NewValue, &newValue); err != nil || newValue.OrganizationID != 1 { + t.Fatalf("audit value=%s err=%v", logs[0].NewValue, err) + } + if _, err = repo.ListByOrganization(0, 20, 0); err == nil { + t.Fatal("unscoped audit read accepted") + } + if err = repo.CreateTx(nil, entry); err == nil { + t.Fatal("nil transaction accepted") + } +} + +func TestOrganizationRepositoryPropagatesDatabaseFailures(t *testing.T) { + db := isolationDB(t, false) + if err := db.Close(); err != nil { + t.Fatal(err) + } + repo := NewOrganizationRepository() + if _, err := repo.ListAccessible(1); err == nil { + t.Fatal("ListAccessible swallowed database failure") + } + if allowed, err := repo.CanAccess(1, 1); err == nil || allowed { + t.Fatalf("CanAccess allowed=%v err=%v", allowed, err) + } + audit := NewOrganizationAuditRepository() + if err := audit.Create(&model.OrganizationAuditLog{ActorUserID: 1, Action: "test"}); err == nil { + t.Fatal("Create swallowed database failure") + } + if _, err := audit.ListByOrganization(1, 20, 0); err == nil { + t.Fatal("audit read swallowed database failure") + } +} diff --git a/internal/repository/organization_audit.go b/internal/repository/organization_audit.go new file mode 100644 index 00000000..b1c37980 --- /dev/null +++ b/internal/repository/organization_audit.go @@ -0,0 +1,73 @@ +package repository + +import ( + "database/sql" + "fmt" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" +) + +type OrganizationAuditRepository struct{} + +func NewOrganizationAuditRepository() *OrganizationAuditRepository { + return &OrganizationAuditRepository{} +} + +type auditExecutor interface { + Exec(string, ...interface{}) (sql.Result, error) +} + +func writeOrganizationAudit(q auditExecutor, entry *model.OrganizationAuditLog) error { + if entry == nil { + return fmt.Errorf("audit entry is required") + } + result, err := q.Exec(`INSERT INTO organization_audit_logs(actor_user_id,organization_id,action,old_value,new_value) VALUES(?,?,?,?,?)`, entry.ActorUserID, entry.OrganizationID, entry.Action, nullableAuditJSON(entry.OldValue), nullableAuditJSON(entry.NewValue)) + if err != nil { + return err + } + entry.ID, err = result.LastInsertId() + return err +} +func nullableAuditJSON(value []byte) interface{} { + if len(value) == 0 { + return nil + } + return string(value) +} +func (r *OrganizationAuditRepository) Create(entry *model.OrganizationAuditLog) error { + return writeOrganizationAudit(database.DB, entry) +} + +// The caller owns commit/rollback; audit errors must roll back its mutation. +func (r *OrganizationAuditRepository) CreateTx(tx *sql.Tx, entry *model.OrganizationAuditLog) error { + if tx == nil { + return fmt.Errorf("audit transaction is required") + } + return writeOrganizationAudit(tx, entry) +} +func (r *OrganizationAuditRepository) ListByOrganization(organizationID int64, limit, offset int) ([]model.OrganizationAuditLog, error) { + if organizationID <= 0 || limit <= 0 || limit > 500 || offset < 0 { + return nil, fmt.Errorf("valid organization and pagination are required") + } + rows, err := database.DB.Query(`SELECT id,actor_user_id,organization_id,action,old_value,new_value,created_at FROM organization_audit_logs WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?`, organizationID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + entries := make([]model.OrganizationAuditLog, 0) + for rows.Next() { + var item model.OrganizationAuditLog + var org sql.NullInt64 + var oldValue, newValue []byte + if err := rows.Scan(&item.ID, &item.ActorUserID, &org, &item.Action, &oldValue, &newValue, &item.CreatedAt); err != nil { + return nil, err + } + if org.Valid { + item.OrganizationID = &org.Int64 + } + item.OldValue = oldValue + item.NewValue = newValue + entries = append(entries, item) + } + return entries, rows.Err() +} diff --git a/internal/repository/testdata/company-isolation-legacy.sql b/internal/repository/testdata/company-isolation-legacy.sql new file mode 100644 index 00000000..cf0e102c --- /dev/null +++ b/internal/repository/testdata/company-isolation-legacy.sql @@ -0,0 +1,40 @@ +-- WMMS fresh schema for MySQL 8.0. Executed once for an empty Docker volume. +-- SET NAMES must come first: the mysql client that runs this file may default to +-- latin1, which would mangle every Chinese literal below into mojibake. +SET NAMES utf8mb4; +CREATE DATABASE IF NOT EXISTS wmms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE wmms; + +CREATE TABLE users (id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, description VARCHAR(500) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE organizations (id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', default_timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', 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; +CREATE TABLE roles (id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(80) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, 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; +CREATE TABLE permissions (id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(120) NOT NULL UNIQUE, name VARCHAR(150) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE role_permissions (role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(role_id,permission_id), FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE, FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE user_roles (user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,role_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE user_organizations (user_id BIGINT NOT NULL, organization_id BIGINT NOT NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,organization_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE revoked_tokens (jti VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NULL, expires_at DATETIME NOT NULL, revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_revoked_tokens_expires_at(expires_at), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +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 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_unit_conversions (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, from_unit_id BIGINT NOT NULL, to_unit_id BIGINT NOT NULL, conversion_rate DECIMAL(20,6) NOT NULL, effective_from DATE NOT NULL, effective_to DATE NULL, KEY idx_material_unit_conversions_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(from_unit_id) REFERENCES units(id), FOREIGN KEY(to_unit_id) REFERENCES units(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, tag_code VARCHAR(100) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tags_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE business_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, legal_name VARCHAR(200) NOT NULL, unified_credit_code VARCHAR(50) UNIQUE, contact_name VARCHAR(100), contact_phone VARCHAR(50), address VARCHAR(500), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- 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 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), 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), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE user_warehouses (user_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,warehouse_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE inventory_balances (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, material_id BIGINT NOT NULL, on_hand DECIMAL(20,6) NOT NULL DEFAULT 0, reserved DECIMAL(20,6) NOT NULL DEFAULT 0, locked DECIMAL(20,6) NOT NULL DEFAULT 0, in_transit DECIMAL(20,6) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_inventory_balance_dimension(organization_id,warehouse_id,location_id,material_id), KEY idx_inventory_balances_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE stock_warning_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, material_id BIGINT NOT NULL, minimum_available DECIMAL(20,6) NOT NULL DEFAULT 0, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_stock_warning_rules_dimension(organization_id,warehouse_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE inbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, inbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), inbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, supplier VARCHAR(200), remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_inbound_orders_date(inbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE inbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, inbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inbound_items_order(inbound_order_id), FOREIGN KEY(inbound_order_id) REFERENCES inbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE outbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, outbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_outbound_items_order(outbound_order_id), FOREIGN KEY(outbound_order_id) REFERENCES outbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE material_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, operation_type VARCHAR(50) NOT NULL, material_id BIGINT NOT NULL, old_value TEXT, new_value TEXT, operator VARCHAR(100), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_logs_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE material_applications (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, purpose VARCHAR(500) NOT NULL DEFAULT '', status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', rejection_reason VARCHAR(500) NOT NULL DEFAULT '', approved_by BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE application_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, application_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, FOREIGN KEY(application_id) REFERENCES material_applications(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/checks/company-master-data-integrity.sql b/scripts/checks/company-master-data-integrity.sql new file mode 100644 index 00000000..77cbbf31 --- /dev/null +++ b/scripts/checks/company-master-data-integrity.sql @@ -0,0 +1,122 @@ +-- MySQL 8.0. Run in the target database before upgrading, with application writes stopped. +-- Every row in the final result is a BLOCKER: repair with business confirmation. +-- This check works on legacy and upgraded schemas; inferred dimensions never use company 1. +-- Canonical source: after edits run pwsh -NoProfile -File scripts/sync-company-integrity.ps1 +-- Verify the self-contained migration with the same command plus -Check. +-- BEGIN COMPANY INTEGRITY CORE +DROP TEMPORARY TABLE IF EXISTS tmp_company_application_dimensions; +CREATE TEMPORARY TABLE tmp_company_application_dimensions AS +SELECT a.id, COUNT(ai.id) item_count, COUNT(DISTINCT m.organization_id) organization_count, + MIN(m.organization_id) organization_id, COUNT(m.id) material_count +FROM material_applications a LEFT JOIN application_items ai ON ai.application_id=a.id +LEFT JOIN materials m ON m.id=ai.material_id GROUP BY a.id; + +DROP TEMPORARY TABLE IF EXISTS tmp_company_log_dimensions; +CREATE TEMPORARY TABLE tmp_company_log_dimensions AS +SELECT s.id, o.organization_id, o.warehouse_id, o.location_id +FROM stock_logs s JOIN inbound_orders o ON s.related_order_type='inbound' AND s.related_order_id=o.id +JOIN inbound_items i ON i.inbound_order_id=o.id AND i.material_id=s.material_id +WHERE s.type='in' AND s.quantity=i.quantity +UNION +SELECT s.id, o.organization_id, o.warehouse_id, o.location_id +FROM stock_logs s JOIN outbound_orders o ON s.related_order_type='outbound' AND s.related_order_id=o.id +JOIN outbound_items i ON i.outbound_order_id=o.id AND i.material_id=s.material_id +WHERE s.type='out' AND s.quantity=i.quantity +UNION +SELECT s.id, d.organization_id, b.warehouse_id, b.location_id +FROM stock_logs s JOIN material_applications a ON s.related_order_type='application' AND s.related_order_id=a.id +JOIN tmp_company_application_dimensions d ON d.id=a.id AND d.item_count>0 AND d.organization_count=1 AND d.material_count=d.item_count +JOIN application_items i ON i.application_id=a.id AND i.material_id=s.material_id +JOIN inventory_balances b ON b.organization_id=d.organization_id AND b.material_id=s.material_id +JOIN warehouses w ON w.id=b.warehouse_id AND w.organization_id=d.organization_id +JOIN warehouse_locations l ON l.id=b.location_id AND l.warehouse_id=w.id +WHERE s.type='out' AND a.status='approved' AND s.quantity=i.quantity; + +DROP TEMPORARY TABLE IF EXISTS tmp_company_integrity_anomalies; +CREATE TEMPORARY TABLE tmp_company_integrity_anomalies ( + issue VARCHAR(100) NOT NULL, entity_id BIGINT NOT NULL, detail VARCHAR(500) NOT NULL +); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'category_parent',c.id,'Parent category belongs to another company or is missing' +FROM material_categories c LEFT JOIN material_categories p ON p.id=c.parent_id +WHERE c.parent_id IS NOT NULL AND (p.id IS NULL OR p.organization_id<>c.organization_id); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'material_category_unit_type',m.id,'Material category, base unit or type belongs to another company or is missing' +FROM materials m LEFT JOIN material_categories c ON c.id=m.category_id +LEFT JOIN units u ON u.id=m.base_unit_id LEFT JOIN material_types mt ON mt.id=m.material_type_id +WHERE c.id IS NULL OR c.organization_id<>m.organization_id +OR (m.base_unit_id IS NOT NULL AND (u.id IS NULL OR u.organization_id<>m.organization_id)) +OR (m.material_type_id IS NOT NULL AND (mt.id IS NULL OR mt.organization_id<>m.organization_id)); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'rule_category',r.id,'Code rule category belongs to another company or is missing' +FROM code_rules r LEFT JOIN material_categories c ON c.id=r.category_id +WHERE c.id IS NULL OR c.organization_id<>r.organization_id; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'duplicate_default',user_id,'User has more than one default organization' +FROM user_organizations WHERE is_default<>0 GROUP BY user_id HAVING COUNT(*)>1; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'user_without_access',u.id,'User has no enabled company grant and no enabled super_admin role' +FROM users u WHERE NOT EXISTS(SELECT 1 FROM user_organizations g JOIN organizations o ON o.id=g.organization_id AND o.status='active' WHERE g.user_id=u.id) +AND NOT EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON r.id=ur.role_id AND r.code='super_admin' AND r.enabled=1 WHERE ur.user_id=u.id); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'multiple_warehouses',organization_id,'Company has multiple warehouses (including inactive ones)' +FROM warehouses GROUP BY organization_id HAVING COUNT(*)>1; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'multiple_locations',warehouse_id,'Warehouse has multiple locations (including inactive ones)' +FROM warehouse_locations GROUP BY warehouse_id HAVING COUNT(*)>1; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'inventory_dimensions',b.id,'Inventory material, company, warehouse or location do not agree' +FROM inventory_balances b LEFT JOIN materials m ON m.id=b.material_id LEFT JOIN warehouses w ON w.id=b.warehouse_id +LEFT JOIN warehouse_locations l ON l.id=b.location_id LEFT JOIN organizations o ON o.id=b.organization_id +WHERE o.id IS NULL OR m.id IS NULL OR w.id IS NULL OR l.id IS NULL OR m.organization_id<>b.organization_id OR w.organization_id<>b.organization_id OR l.warehouse_id<>b.warehouse_id; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'warning_dimensions',r.id,'Warning material, company or warehouse do not agree' +FROM stock_warning_rules r LEFT JOIN materials m ON m.id=r.material_id LEFT JOIN warehouses w ON w.id=r.warehouse_id +WHERE m.id IS NULL OR w.id IS NULL OR m.organization_id<>r.organization_id OR w.organization_id<>r.organization_id; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'inbound_dimensions',o.id,'Inbound company, warehouse, location, materials or actual inventory dimensions do not agree' +FROM inbound_orders o LEFT JOIN warehouses w ON w.id=o.warehouse_id LEFT JOIN warehouse_locations l ON l.id=o.location_id +WHERE w.id IS NULL OR l.id IS NULL OR w.organization_id<>o.organization_id OR l.warehouse_id<>o.warehouse_id +OR NOT EXISTS(SELECT 1 FROM inbound_items i WHERE i.inbound_order_id=o.id) +OR EXISTS(SELECT 1 FROM inbound_items i LEFT JOIN materials m ON m.id=i.material_id + WHERE i.inbound_order_id=o.id AND (m.id IS NULL OR m.organization_id<>o.organization_id + OR NOT EXISTS(SELECT 1 FROM inventory_balances b WHERE b.material_id=i.material_id AND b.organization_id=o.organization_id AND b.warehouse_id=o.warehouse_id AND b.location_id=o.location_id))); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'outbound_dimensions',o.id,'Outbound company, warehouse, location, materials or actual inventory dimensions do not agree' +FROM outbound_orders o LEFT JOIN warehouses w ON w.id=o.warehouse_id LEFT JOIN warehouse_locations l ON l.id=o.location_id +WHERE w.id IS NULL OR l.id IS NULL OR w.organization_id<>o.organization_id OR l.warehouse_id<>o.warehouse_id +OR NOT EXISTS(SELECT 1 FROM outbound_items i WHERE i.outbound_order_id=o.id) +OR EXISTS(SELECT 1 FROM outbound_items i LEFT JOIN materials m ON m.id=i.material_id + WHERE i.outbound_order_id=o.id AND (m.id IS NULL OR m.organization_id<>o.organization_id + OR NOT EXISTS(SELECT 1 FROM inventory_balances b WHERE b.material_id=i.material_id AND b.organization_id=o.organization_id AND b.warehouse_id=o.warehouse_id AND b.location_id=o.location_id))); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'application_dimensions',id,'Application has empty, missing or mixed-company material details' +FROM tmp_company_application_dimensions WHERE item_count=0 OR organization_count<>1 OR material_count<>item_count; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'unattributable_stock_log',s.id,'Log has no unique verified material/document/company/warehouse/location association' +FROM stock_logs s LEFT JOIN tmp_company_log_dimensions d ON d.id=s.id +LEFT JOIN materials m ON m.id=s.material_id LEFT JOIN warehouses w ON w.id=d.warehouse_id LEFT JOIN warehouse_locations l ON l.id=d.location_id +GROUP BY s.id,m.id,m.organization_id +HAVING COUNT(d.id)<>1 OR COUNT(m.id)=0 OR SUM(d.organization_id<>m.organization_id OR w.organization_id<>d.organization_id OR l.warehouse_id<>d.warehouse_id)>0; + +-- The same check file must work before the columns exist and after backfill. +SET @company_check_sql = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='stock_logs' AND column_name IN ('organization_id','warehouse_id','location_id'))=3, + 'INSERT INTO tmp_company_integrity_anomalies SELECT ''stored_stock_log_dimensions'',s.id,''Stored log dimensions differ from verified source document'' FROM stock_logs s LEFT JOIN tmp_company_log_dimensions d ON d.id=s.id WHERE d.id IS NULL OR s.organization_id IS NULL OR s.warehouse_id IS NULL OR s.location_id IS NULL OR s.organization_id<>d.organization_id OR s.warehouse_id<>d.warehouse_id OR s.location_id<>d.location_id', + 'DO 0'); +PREPARE company_check FROM @company_check_sql; +EXECUTE company_check; +DEALLOCATE PREPARE company_check; +SET @company_check_sql = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='material_applications' AND column_name='organization_id')=1, + 'INSERT INTO tmp_company_integrity_anomalies SELECT ''stored_application_dimensions'',a.id,''Stored application company differs from verified detail materials'' FROM material_applications a JOIN tmp_company_application_dimensions d ON d.id=a.id WHERE a.organization_id IS NULL OR a.organization_id<>d.organization_id', + 'DO 0'); +PREPARE company_check FROM @company_check_sql; +EXECUTE company_check; +DEALLOCATE PREPARE company_check; + +-- END COMPANY INTEGRITY CORE +SELECT issue, entity_id, detail FROM tmp_company_integrity_anomalies ORDER BY issue,entity_id; +-- Counts are also review evidence, even when zero or one per dimension. +SELECT organization_id,COUNT(*) warehouse_count FROM warehouses GROUP BY organization_id; +SELECT warehouse_id,COUNT(*) location_count FROM warehouse_locations GROUP BY warehouse_id; diff --git a/scripts/init.sql b/scripts/init.sql index b0255f4d..7067050a 100644 --- a/scripts/init.sql +++ b/scripts/init.sql @@ -11,7 +11,7 @@ CREATE TABLE roles (id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(80) NOT N CREATE TABLE permissions (id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(120) NOT NULL UNIQUE, name VARCHAR(150) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE role_permissions (role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(role_id,permission_id), FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE, FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE user_roles (user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,role_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE user_organizations (user_id BIGINT NOT NULL, organization_id BIGINT NOT NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,organization_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE user_organizations (user_id BIGINT NOT NULL, organization_id BIGINT NOT NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, default_user_id BIGINT GENERATED ALWAYS AS (CASE WHEN is_default THEN user_id ELSE NULL END) VIRTUAL, UNIQUE KEY uk_user_organizations_default(default_user_id), PRIMARY KEY(user_id,organization_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE revoked_tokens (jti VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NULL, expires_at DATETIME NOT NULL, revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_revoked_tokens_expires_at(expires_at), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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; @@ -25,20 +25,41 @@ CREATE TABLE business_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, legal_name -- 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 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), 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), FOREIGN KEY(warehouse_id) REFERENCES warehouses(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; CREATE TABLE user_warehouses (user_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(user_id,warehouse_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE inventory_balances (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, material_id BIGINT NOT NULL, on_hand DECIMAL(20,6) NOT NULL DEFAULT 0, reserved DECIMAL(20,6) NOT NULL DEFAULT 0, locked DECIMAL(20,6) NOT NULL DEFAULT 0, in_transit DECIMAL(20,6) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_inventory_balance_dimension(organization_id,warehouse_id,location_id,material_id), KEY idx_inventory_balances_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock_warning_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, material_id BIGINT NOT NULL, minimum_available DECIMAL(20,6) NOT NULL DEFAULT 0, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_stock_warning_rules_dimension(organization_id,warehouse_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE inbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, inbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), inbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, supplier VARCHAR(200), remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_inbound_orders_date(inbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE inbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, inbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), inbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, supplier VARCHAR(200), remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_inbound_orders_date(inbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE inbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, inbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inbound_items_order(inbound_order_id), FOREIGN KEY(inbound_order_id) REFERENCES inbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE outbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, outbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_outbound_items_order(outbound_order_id), FOREIGN KEY(outbound_order_id) REFERENCES outbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE material_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, operation_type VARCHAR(50) NOT NULL, material_id BIGINT NOT NULL, old_value TEXT, new_value TEXT, operator VARCHAR(100), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_logs_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE material_applications (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, purpose VARCHAR(500) NOT NULL DEFAULT '', status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', rejection_reason VARCHAR(500) NOT NULL DEFAULT '', approved_by BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, KEY idx_stock_logs_org_material_created(organization_id,material_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id), material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE material_applications (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, KEY idx_material_applications_org_user_created(organization_id,user_id,created_at), KEY idx_material_applications_org_status_created(organization_id,status,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id), user_id BIGINT NOT NULL, purpose VARCHAR(500) NOT NULL DEFAULT '', status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', rejection_reason VARCHAR(500) NOT NULL DEFAULT '', approved_by BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE application_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, application_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, FOREIGN KEY(application_id) REFERENCES material_applications(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(100) PRIMARY KEY, status VARCHAR(20) NOT NULL, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME NULL, + details JSON NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS schema_migration_reconciliations ( + version VARCHAR(100) NOT NULL, phase VARCHAR(10) NOT NULL, organization_id BIGINT NOT NULL, + on_hand DECIMAL(30,6) NOT NULL, reserved DECIMAL(30,6) NOT NULL, locked DECIMAL(30,6) NOT NULL, + in_transit DECIMAL(30,6) NOT NULL, inbound_count BIGINT NOT NULL, outbound_count BIGINT NOT NULL, + application_count BIGINT NOT NULL, stock_log_count BIGINT NOT NULL, captured_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(version,phase,organization_id), FOREIGN KEY(organization_id) REFERENCES organizations(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE organization_audit_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY,actor_user_id BIGINT NOT NULL,organization_id BIGINT NULL, + action VARCHAR(100) NOT NULL,old_value JSON NULL,new_value JSON NULL,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_organization_audit_org_created(organization_id,created_at),KEY idx_organization_audit_actor_created(actor_user_id,created_at), + FOREIGN KEY(actor_user_id) REFERENCES users(id),FOREIGN KEY(organization_id) REFERENCES organizations(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-08-company-master-data-isolation','completed',NOW(),JSON_OBJECT('version','2026-10-08-company-master-data-isolation','source','fresh-init')); + INSERT INTO organizations (id,code,name,status) VALUES (1,'DEFAULT','默认公司组织','active'); INSERT INTO roles (code,name) VALUES ('super_admin','超级管理员'),('warehouse_admin','仓库管理员'),('purchaser','采购人员'),('technician','技术人员'),('production','生产人员'),('sales','销售人员'); INSERT INTO permissions (code,name) VALUES ('master.material.read','查看物料'),('master.material.write','维护物料'),('master.config.write','维护物料基础配置'),('organization.read','查看公司组织'),('organization.write','维护公司组织'),('organization.user.write','维护用户'),('organization.role.write','维护角色与权限'),('partner.supplier.read','查看供应商'),('partner.supplier.write','维护供应商'),('partner.customer.read','查看客户'),('partner.customer.write','维护客户'),('inventory.stock.read','查看库存'),('inventory.warning.write','维护库存预警'),('inventory.inbound.write','维护入库'),('inventory.outbound.write','维护出库'); diff --git a/scripts/sync-company-integrity.ps1 b/scripts/sync-company-integrity.ps1 new file mode 100644 index 00000000..63874e7f --- /dev/null +++ b/scripts/sync-company-integrity.ps1 @@ -0,0 +1,58 @@ +# 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 = (Join-Path $PSScriptRoot 'checks/company-master-data-integrity.sql'), + [string]$MigrationPath = (Join-Path $PSScriptRoot 'upgrades/2026-10-08-company-master-data-isolation.sql'), + [switch]$Check +) + +$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 +} diff --git a/scripts/upgrades/2026-10-08-company-master-data-isolation.sql b/scripts/upgrades/2026-10-08-company-master-data-isolation.sql new file mode 100644 index 00000000..5d8d1640 --- /dev/null +++ b/scripts/upgrades/2026-10-08-company-master-data-isolation.sql @@ -0,0 +1,224 @@ +-- MySQL 8.0; run once in the selected database, after backup and stopping writes. +-- Task 1 cannot deploy alone: Tasks 5A-5C must supply explicit dimensions in INSERTs. +-- MySQL DDL implicitly commits. A failed/running version requires manual recovery +-- from backup; never delete its version record and blindly rerun partial DDL. +SET NAMES utf8mb4; +CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(100) PRIMARY KEY, status VARCHAR(20) NOT NULL, + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME NULL, + details JSON NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS schema_migration_reconciliations ( + version VARCHAR(100) NOT NULL, phase VARCHAR(10) NOT NULL, organization_id BIGINT NOT NULL, + on_hand DECIMAL(30,6) NOT NULL, reserved DECIMAL(30,6) NOT NULL, locked DECIMAL(30,6) NOT NULL, + in_transit DECIMAL(30,6) NOT NULL, inbound_count BIGINT NOT NULL, outbound_count BIGINT NOT NULL, + application_count BIGINT NOT NULL, stock_log_count BIGINT NOT NULL, captured_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(version,phase,organization_id), FOREIGN KEY(organization_id) REFERENCES organizations(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +DELIMITER $$ +DROP PROCEDURE IF EXISTS upgrade_company_isolation_20261008$$ +CREATE PROCEDURE upgrade_company_isolation_20261008() +BEGIN + DECLARE lock_acquired INT DEFAULT 0; + DECLARE version_started BOOLEAN DEFAULT FALSE; + DECLARE migration_version VARCHAR(100) DEFAULT '2026-10-08-company-master-data-isolation'; + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + IF version_started THEN + UPDATE schema_migrations SET status='failed',details=JSON_SET(details,'$.recovery','DDL may have committed; restore backup or review partial schema before retry') WHERE version=BINARY migration_version; + END IF; + IF lock_acquired=1 THEN DO RELEASE_LOCK(CONCAT(DATABASE(),':company_isolation')); END IF; + RESIGNAL; + END; + SELECT GET_LOCK(CONCAT(DATABASE(),':company_isolation'),0) INTO lock_acquired; + IF lock_acquired IS NULL OR lock_acquired<>1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation migration is already running'; END IF; + IF EXISTS(SELECT 1 FROM schema_migrations WHERE version=BINARY migration_version) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation migration version already recorded; do not rerun'; + END IF; + + -- Generated from scripts/checks/company-master-data-integrity.sql; edit that source. + -- Sync: pwsh -NoProfile -File scripts/sync-company-integrity.ps1 (verify with -Check). +-- BEGIN GENERATED COMPANY INTEGRITY CHECKS +DROP TEMPORARY TABLE IF EXISTS tmp_company_application_dimensions; +CREATE TEMPORARY TABLE tmp_company_application_dimensions AS +SELECT a.id, COUNT(ai.id) item_count, COUNT(DISTINCT m.organization_id) organization_count, + MIN(m.organization_id) organization_id, COUNT(m.id) material_count +FROM material_applications a LEFT JOIN application_items ai ON ai.application_id=a.id +LEFT JOIN materials m ON m.id=ai.material_id GROUP BY a.id; + +DROP TEMPORARY TABLE IF EXISTS tmp_company_log_dimensions; +CREATE TEMPORARY TABLE tmp_company_log_dimensions AS +SELECT s.id, o.organization_id, o.warehouse_id, o.location_id +FROM stock_logs s JOIN inbound_orders o ON s.related_order_type='inbound' AND s.related_order_id=o.id +JOIN inbound_items i ON i.inbound_order_id=o.id AND i.material_id=s.material_id +WHERE s.type='in' AND s.quantity=i.quantity +UNION +SELECT s.id, o.organization_id, o.warehouse_id, o.location_id +FROM stock_logs s JOIN outbound_orders o ON s.related_order_type='outbound' AND s.related_order_id=o.id +JOIN outbound_items i ON i.outbound_order_id=o.id AND i.material_id=s.material_id +WHERE s.type='out' AND s.quantity=i.quantity +UNION +SELECT s.id, d.organization_id, b.warehouse_id, b.location_id +FROM stock_logs s JOIN material_applications a ON s.related_order_type='application' AND s.related_order_id=a.id +JOIN tmp_company_application_dimensions d ON d.id=a.id AND d.item_count>0 AND d.organization_count=1 AND d.material_count=d.item_count +JOIN application_items i ON i.application_id=a.id AND i.material_id=s.material_id +JOIN inventory_balances b ON b.organization_id=d.organization_id AND b.material_id=s.material_id +JOIN warehouses w ON w.id=b.warehouse_id AND w.organization_id=d.organization_id +JOIN warehouse_locations l ON l.id=b.location_id AND l.warehouse_id=w.id +WHERE s.type='out' AND a.status='approved' AND s.quantity=i.quantity; + +DROP TEMPORARY TABLE IF EXISTS tmp_company_integrity_anomalies; +CREATE TEMPORARY TABLE tmp_company_integrity_anomalies ( + issue VARCHAR(100) NOT NULL, entity_id BIGINT NOT NULL, detail VARCHAR(500) NOT NULL +); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'category_parent',c.id,'Parent category belongs to another company or is missing' +FROM material_categories c LEFT JOIN material_categories p ON p.id=c.parent_id +WHERE c.parent_id IS NOT NULL AND (p.id IS NULL OR p.organization_id<>c.organization_id); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'material_category_unit_type',m.id,'Material category, base unit or type belongs to another company or is missing' +FROM materials m LEFT JOIN material_categories c ON c.id=m.category_id +LEFT JOIN units u ON u.id=m.base_unit_id LEFT JOIN material_types mt ON mt.id=m.material_type_id +WHERE c.id IS NULL OR c.organization_id<>m.organization_id +OR (m.base_unit_id IS NOT NULL AND (u.id IS NULL OR u.organization_id<>m.organization_id)) +OR (m.material_type_id IS NOT NULL AND (mt.id IS NULL OR mt.organization_id<>m.organization_id)); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'rule_category',r.id,'Code rule category belongs to another company or is missing' +FROM code_rules r LEFT JOIN material_categories c ON c.id=r.category_id +WHERE c.id IS NULL OR c.organization_id<>r.organization_id; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'duplicate_default',user_id,'User has more than one default organization' +FROM user_organizations WHERE is_default<>0 GROUP BY user_id HAVING COUNT(*)>1; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'user_without_access',u.id,'User has no enabled company grant and no enabled super_admin role' +FROM users u WHERE NOT EXISTS(SELECT 1 FROM user_organizations g JOIN organizations o ON o.id=g.organization_id AND o.status='active' WHERE g.user_id=u.id) +AND NOT EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON r.id=ur.role_id AND r.code='super_admin' AND r.enabled=1 WHERE ur.user_id=u.id); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'multiple_warehouses',organization_id,'Company has multiple warehouses (including inactive ones)' +FROM warehouses GROUP BY organization_id HAVING COUNT(*)>1; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'multiple_locations',warehouse_id,'Warehouse has multiple locations (including inactive ones)' +FROM warehouse_locations GROUP BY warehouse_id HAVING COUNT(*)>1; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'inventory_dimensions',b.id,'Inventory material, company, warehouse or location do not agree' +FROM inventory_balances b LEFT JOIN materials m ON m.id=b.material_id LEFT JOIN warehouses w ON w.id=b.warehouse_id +LEFT JOIN warehouse_locations l ON l.id=b.location_id LEFT JOIN organizations o ON o.id=b.organization_id +WHERE o.id IS NULL OR m.id IS NULL OR w.id IS NULL OR l.id IS NULL OR m.organization_id<>b.organization_id OR w.organization_id<>b.organization_id OR l.warehouse_id<>b.warehouse_id; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'warning_dimensions',r.id,'Warning material, company or warehouse do not agree' +FROM stock_warning_rules r LEFT JOIN materials m ON m.id=r.material_id LEFT JOIN warehouses w ON w.id=r.warehouse_id +WHERE m.id IS NULL OR w.id IS NULL OR m.organization_id<>r.organization_id OR w.organization_id<>r.organization_id; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'inbound_dimensions',o.id,'Inbound company, warehouse, location, materials or actual inventory dimensions do not agree' +FROM inbound_orders o LEFT JOIN warehouses w ON w.id=o.warehouse_id LEFT JOIN warehouse_locations l ON l.id=o.location_id +WHERE w.id IS NULL OR l.id IS NULL OR w.organization_id<>o.organization_id OR l.warehouse_id<>o.warehouse_id +OR NOT EXISTS(SELECT 1 FROM inbound_items i WHERE i.inbound_order_id=o.id) +OR EXISTS(SELECT 1 FROM inbound_items i LEFT JOIN materials m ON m.id=i.material_id + WHERE i.inbound_order_id=o.id AND (m.id IS NULL OR m.organization_id<>o.organization_id + OR NOT EXISTS(SELECT 1 FROM inventory_balances b WHERE b.material_id=i.material_id AND b.organization_id=o.organization_id AND b.warehouse_id=o.warehouse_id AND b.location_id=o.location_id))); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'outbound_dimensions',o.id,'Outbound company, warehouse, location, materials or actual inventory dimensions do not agree' +FROM outbound_orders o LEFT JOIN warehouses w ON w.id=o.warehouse_id LEFT JOIN warehouse_locations l ON l.id=o.location_id +WHERE w.id IS NULL OR l.id IS NULL OR w.organization_id<>o.organization_id OR l.warehouse_id<>o.warehouse_id +OR NOT EXISTS(SELECT 1 FROM outbound_items i WHERE i.outbound_order_id=o.id) +OR EXISTS(SELECT 1 FROM outbound_items i LEFT JOIN materials m ON m.id=i.material_id + WHERE i.outbound_order_id=o.id AND (m.id IS NULL OR m.organization_id<>o.organization_id + OR NOT EXISTS(SELECT 1 FROM inventory_balances b WHERE b.material_id=i.material_id AND b.organization_id=o.organization_id AND b.warehouse_id=o.warehouse_id AND b.location_id=o.location_id))); +INSERT INTO tmp_company_integrity_anomalies +SELECT 'application_dimensions',id,'Application has empty, missing or mixed-company material details' +FROM tmp_company_application_dimensions WHERE item_count=0 OR organization_count<>1 OR material_count<>item_count; +INSERT INTO tmp_company_integrity_anomalies +SELECT 'unattributable_stock_log',s.id,'Log has no unique verified material/document/company/warehouse/location association' +FROM stock_logs s LEFT JOIN tmp_company_log_dimensions d ON d.id=s.id +LEFT JOIN materials m ON m.id=s.material_id LEFT JOIN warehouses w ON w.id=d.warehouse_id LEFT JOIN warehouse_locations l ON l.id=d.location_id +GROUP BY s.id,m.id,m.organization_id +HAVING COUNT(d.id)<>1 OR COUNT(m.id)=0 OR SUM(d.organization_id<>m.organization_id OR w.organization_id<>d.organization_id OR l.warehouse_id<>d.warehouse_id)>0; + +-- The same check file must work before the columns exist and after backfill. +SET @company_check_sql = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='stock_logs' AND column_name IN ('organization_id','warehouse_id','location_id'))=3, + 'INSERT INTO tmp_company_integrity_anomalies SELECT ''stored_stock_log_dimensions'',s.id,''Stored log dimensions differ from verified source document'' FROM stock_logs s LEFT JOIN tmp_company_log_dimensions d ON d.id=s.id WHERE d.id IS NULL OR s.organization_id IS NULL OR s.warehouse_id IS NULL OR s.location_id IS NULL OR s.organization_id<>d.organization_id OR s.warehouse_id<>d.warehouse_id OR s.location_id<>d.location_id', + 'DO 0'); +PREPARE company_check FROM @company_check_sql; +EXECUTE company_check; +DEALLOCATE PREPARE company_check; +SET @company_check_sql = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='material_applications' AND column_name='organization_id')=1, + 'INSERT INTO tmp_company_integrity_anomalies SELECT ''stored_application_dimensions'',a.id,''Stored application company differs from verified detail materials'' FROM material_applications a JOIN tmp_company_application_dimensions d ON d.id=a.id WHERE a.organization_id IS NULL OR a.organization_id<>d.organization_id', + 'DO 0'); +PREPARE company_check FROM @company_check_sql; +EXECUTE company_check; +DEALLOCATE PREPARE company_check; + +-- END GENERATED COMPANY INTEGRITY CHECKS + IF EXISTS(SELECT 1 FROM tmp_company_integrity_anomalies) THEN + SELECT issue,entity_id,detail FROM tmp_company_integrity_anomalies ORDER BY issue,entity_id; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation integrity anomalies; run scripts/checks/company-master-data-integrity.sql and repair before upgrade'; + END IF; + + INSERT INTO schema_migrations(version,status,details) VALUES(migration_version,'running',JSON_OBJECT('version',migration_version,'executor',CURRENT_USER())); + SET version_started=TRUE; + INSERT INTO schema_migration_reconciliations(version,phase,organization_id,on_hand,reserved,locked,in_transit,inbound_count,outbound_count,application_count,stock_log_count) + SELECT migration_version,'before',o.id, + COALESCE((SELECT SUM(on_hand) FROM inventory_balances WHERE organization_id=o.id),0), + COALESCE((SELECT SUM(reserved) FROM inventory_balances WHERE organization_id=o.id),0), + COALESCE((SELECT SUM(locked) FROM inventory_balances WHERE organization_id=o.id),0), + COALESCE((SELECT SUM(in_transit) FROM inventory_balances WHERE organization_id=o.id),0), + (SELECT COUNT(*) FROM inbound_orders WHERE organization_id=o.id), + (SELECT COUNT(*) FROM outbound_orders WHERE organization_id=o.id), + (SELECT COUNT(*) FROM tmp_company_application_dimensions WHERE organization_id=o.id), + (SELECT COUNT(*) FROM tmp_company_log_dimensions WHERE organization_id=o.id) + FROM organizations o; + + ALTER TABLE user_organizations ADD COLUMN default_user_id BIGINT GENERATED ALWAYS AS (CASE WHEN is_default THEN user_id ELSE NULL END) VIRTUAL, ADD UNIQUE KEY uk_user_organizations_default(default_user_id); + ALTER TABLE warehouses ADD UNIQUE KEY uk_warehouses_organization(organization_id); + ALTER TABLE warehouse_locations ADD UNIQUE KEY uk_warehouse_locations_warehouse(warehouse_id); + CREATE TABLE organization_audit_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY,actor_user_id BIGINT NOT NULL,organization_id BIGINT NULL, + action VARCHAR(100) NOT NULL,old_value JSON NULL,new_value JSON NULL,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_organization_audit_org_created(organization_id,created_at),KEY idx_organization_audit_actor_created(actor_user_id,created_at), + FOREIGN KEY(actor_user_id) REFERENCES users(id),FOREIGN KEY(organization_id) REFERENCES organizations(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + ALTER TABLE material_applications ADD COLUMN organization_id BIGINT NULL; + ALTER TABLE stock_logs ADD COLUMN organization_id BIGINT NULL,ADD COLUMN warehouse_id BIGINT NULL,ADD COLUMN location_id BIGINT NULL; + UPDATE material_applications a JOIN tmp_company_application_dimensions d ON d.id=a.id SET a.organization_id=d.organization_id; + UPDATE stock_logs s JOIN tmp_company_log_dimensions d ON d.id=s.id SET s.organization_id=d.organization_id,s.warehouse_id=d.warehouse_id,s.location_id=d.location_id; + IF EXISTS(SELECT 1 FROM material_applications WHERE organization_id IS NULL) + OR EXISTS(SELECT 1 FROM stock_logs WHERE organization_id IS NULL OR warehouse_id IS NULL OR location_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation backfill incomplete; restore backup and investigate'; + END IF; + ALTER TABLE material_applications MODIFY organization_id BIGINT NOT NULL, + ADD KEY idx_material_applications_org_user_created(organization_id,user_id,created_at), + ADD KEY idx_material_applications_org_status_created(organization_id,status,created_at), + ADD FOREIGN KEY(organization_id) REFERENCES organizations(id); + ALTER TABLE stock_logs MODIFY organization_id BIGINT NOT NULL,MODIFY warehouse_id BIGINT NOT NULL,MODIFY location_id BIGINT NOT NULL, + ADD KEY idx_stock_logs_org_material_created(organization_id,material_id,created_at), + ADD FOREIGN KEY(organization_id) REFERENCES organizations(id),ADD FOREIGN KEY(warehouse_id) REFERENCES warehouses(id),ADD FOREIGN KEY(location_id) REFERENCES warehouse_locations(id); + ALTER TABLE inbound_orders ALTER COLUMN organization_id DROP DEFAULT,ALTER COLUMN warehouse_id DROP DEFAULT,ALTER COLUMN location_id DROP DEFAULT; + ALTER TABLE outbound_orders ALTER COLUMN organization_id DROP DEFAULT,ALTER COLUMN warehouse_id DROP DEFAULT,ALTER COLUMN location_id DROP DEFAULT; + + INSERT INTO schema_migration_reconciliations(version,phase,organization_id,on_hand,reserved,locked,in_transit,inbound_count,outbound_count,application_count,stock_log_count) + SELECT migration_version,'after',o.id, + COALESCE((SELECT SUM(on_hand) FROM inventory_balances WHERE organization_id=o.id),0), + COALESCE((SELECT SUM(reserved) FROM inventory_balances WHERE organization_id=o.id),0), + COALESCE((SELECT SUM(locked) FROM inventory_balances WHERE organization_id=o.id),0), + COALESCE((SELECT SUM(in_transit) FROM inventory_balances WHERE organization_id=o.id),0), + (SELECT COUNT(*) FROM inbound_orders WHERE organization_id=o.id), + (SELECT COUNT(*) FROM outbound_orders WHERE organization_id=o.id), + (SELECT COUNT(*) FROM material_applications WHERE organization_id=o.id), + (SELECT COUNT(*) FROM stock_logs WHERE organization_id=o.id) + FROM organizations o; + IF EXISTS(SELECT 1 FROM schema_migration_reconciliations b JOIN schema_migration_reconciliations a + ON a.version=b.version AND a.organization_id=b.organization_id AND a.phase='after' + WHERE b.version=BINARY migration_version AND b.phase='before' + AND (a.on_hand<>b.on_hand OR a.reserved<>b.reserved OR a.locked<>b.locked OR a.in_transit<>b.in_transit + OR a.inbound_count<>b.inbound_count OR a.outbound_count<>b.outbound_count OR a.application_count<>b.application_count OR a.stock_log_count<>b.stock_log_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation reconciliation mismatch; restore backup and investigate'; + END IF; + UPDATE schema_migrations SET status='completed',completed_at=NOW() WHERE version=BINARY migration_version; + SET version_started=FALSE; + DO RELEASE_LOCK(CONCAT(DATABASE(),':company_isolation')); +END$$ +CALL upgrade_company_isolation_20261008()$$ +DROP PROCEDURE upgrade_company_isolation_20261008$$ +DELIMITER ;