material_manage_system/scripts/upgrades/2026-10-08-company-master-data-isolation.sql

225 lines
17 KiB
SQL

-- 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 ;