feat: 新增组织授权与审计数据底座
This commit is contained in:
parent
cf9301d3c1
commit
7e56d462b3
|
|
@ -1,6 +1,24 @@
|
||||||
package model
|
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 {
|
type Organization struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
|
||||||
133
internal/repository/company_isolation_generator_test.go
Normal file
133
internal/repository/company_isolation_generator_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
452
internal/repository/company_isolation_integration_test.go
Normal file
452
internal/repository/company_isolation_integration_test.go
Normal file
|
|
@ -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,'<null>'),':',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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/repository/organization_access.go
Normal file
48
internal/repository/organization_access.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
186
internal/repository/organization_access_test.go
Normal file
186
internal/repository/organization_access_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/repository/organization_audit.go
Normal file
73
internal/repository/organization_audit.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
40
internal/repository/testdata/company-isolation-legacy.sql
vendored
Normal file
40
internal/repository/testdata/company-isolation-legacy.sql
vendored
Normal file
|
|
@ -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;
|
||||||
122
scripts/checks/company-master-data-integrity.sql
Normal file
122
scripts/checks/company-master-data-integrity.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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 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 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_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 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_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 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.
|
-- an empty set means unclassified. Both fields are created with the table.
|
||||||
CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), supplier_categories SET('material','finished_product') NOT NULL DEFAULT '', customer_status VARCHAR(20), customer_categories SET('material','finished_product') NOT NULL DEFAULT '', settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_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 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 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), FOREIGN KEY(warehouse_id) REFERENCES warehouses(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 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 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 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 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 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 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 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, 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 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 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 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 roles (code,name) VALUES ('super_admin','超级管理员'),('warehouse_admin','仓库管理员'),('purchaser','采购人员'),('technician','技术人员'),('production','生产人员'),('sales','销售人员');
|
||||||
INSERT INTO permissions (code,name) VALUES ('master.material.read','查看物料'),('master.material.write','维护物料'),('master.config.write','维护物料基础配置'),('organization.read','查看公司组织'),('organization.write','维护公司组织'),('organization.user.write','维护用户'),('organization.role.write','维护角色与权限'),('partner.supplier.read','查看供应商'),('partner.supplier.write','维护供应商'),('partner.customer.read','查看客户'),('partner.customer.write','维护客户'),('inventory.stock.read','查看库存'),('inventory.warning.write','维护库存预警'),('inventory.inbound.write','维护入库'),('inventory.outbound.write','维护出库');
|
INSERT INTO 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','维护出库');
|
||||||
|
|
|
||||||
58
scripts/sync-company-integrity.ps1
Normal file
58
scripts/sync-company-integrity.ps1
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
224
scripts/upgrades/2026-10-08-company-master-data-isolation.sql
Normal file
224
scripts/upgrades/2026-10-08-company-master-data-isolation.sql
Normal file
|
|
@ -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 ;
|
||||||
Loading…
Reference in New Issue
Block a user