-- ===================================================================== -- malaysia_stock_db 초기화 스크립트 (PostgreSQL) — 말레이시아 창고 재고관리 -- ===================================================================== -- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다. -- -- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음. -- -- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db): -- -- 1) DB / 역할 / 스키마 생성 (superuser 로 1회) -- read -s -p "malaysia_app password: " APP_PWD; echo -- docker exec -i postgres-db psql -U postgres \ -- -v app_password="$APP_PWD" \ -- < scripts/sql/malaysia_stock_db_init.sql -- -- 2) main-app .env 에 연결 정보 등록 -- MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:@postgres-db:5432/malaysia_stock_db -- -- 3) main-app 재기동 -- cd /opt/www/main && docker compose up -d --build -- -- 주의: -- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만). -- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달. -- - itemcode_db 는 이 스크립트가 건드리지 않는다(상품명은 읽기 전용 참조). -- - 재고 계산 원칙: -- 현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST 부호) + SUM(STOCKTAKE 부호) -- ADJUST/STOCKTAKE 는 음수 qty 허용(±). IN/OUT 은 양수만. -- ===================================================================== \set ON_ERROR_STOP on -- DB 가 없을 때만 생성 SELECT 'CREATE DATABASE malaysia_stock_db ENCODING ''UTF8'' TEMPLATE template0' WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'malaysia_stock_db') \gexec -- 앱 전용 로그인 역할 (cupang_app 패턴과 동일) SELECT 'CREATE ROLE malaysia_app LOGIN PASSWORD ' || quote_literal(:'app_password') WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'malaysia_app') \gexec -- 항상 최신 비밀번호로 동기화 SELECT 'ALTER ROLE malaysia_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password') \gexec GRANT CONNECT ON DATABASE malaysia_stock_db TO malaysia_app; -- malaysia_stock_db 컨텍스트로 전환 \connect malaysia_stock_db -- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ── CREATE OR REPLACE FUNCTION malaysia_set_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- ════════════════════════════════════════════════════════════ -- 1) 창고 -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS warehouses ( id BIGSERIAL PRIMARY KEY, warehouse_code TEXT NOT NULL UNIQUE, warehouse_name TEXT NOT NULL, country TEXT NOT NULL DEFAULT 'MY', active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_warehouses_updated ON warehouses; CREATE TRIGGER trg_warehouses_updated BEFORE UPDATE ON warehouses FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 2) 재고관리 대상 아이템(스코프 카탈로그) -- 상품 마스터를 복제하는 것이 아니라, "말레이시아 창고에서 관리하는 -- 코드 목록과 종류(낱개/세트)"만 보관한다. item_name 은 스냅샷이며 -- itemcode_db(읽기 전용)에서 최신 명칭을 덮어쓸 수 있다. -- kind: 'individual'(MT/MX/MZ) | 'set'(MY). MD- 뚜껑은 제외(미등록). -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS malaysia_items ( id BIGSERIAL PRIMARY KEY, item_code TEXT NOT NULL UNIQUE, item_name TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL CHECK (kind IN ('individual', 'set')), active BOOLEAN NOT NULL DEFAULT TRUE, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- prefix 규칙: 낱개는 MT-/MX-/MZ-, 세트는 MY-. MD- 금지. CHECK ( (kind = 'individual' AND (item_code LIKE 'MT-%' OR item_code LIKE 'MX-%' OR item_code LIKE 'MZ-%')) OR (kind = 'set' AND item_code LIKE 'MY-%') ) ); CREATE INDEX IF NOT EXISTS idx_malaysia_items_kind ON malaysia_items (kind); DROP TRIGGER IF EXISTS trg_malaysia_items_updated ON malaysia_items; CREATE TRIGGER trg_malaysia_items_updated BEFORE UPDATE ON malaysia_items FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 3) 세트 구성표 (BOM) -- set_code(MY-) 안에 component_code(MT-/MX-/MZ-) 가 몇 개 들어가는지. -- MY- 가 MY- 를 포함하는 중첩은 불가(component 는 MY- 금지). MD- 금지. -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS set_bom ( id BIGSERIAL PRIMARY KEY, set_code TEXT NOT NULL CHECK (set_code LIKE 'MY-%'), component_code TEXT NOT NULL CHECK ( component_code LIKE 'MT-%' OR component_code LIKE 'MX-%' OR component_code LIKE 'MZ-%'), component_qty INTEGER NOT NULL CHECK (component_qty > 0), active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (set_code, component_code) ); CREATE INDEX IF NOT EXISTS idx_set_bom_set ON set_bom (set_code); DROP TRIGGER IF EXISTS trg_set_bom_updated ON set_bom; CREATE TRIGGER trg_set_bom_updated BEFORE UPDATE ON set_bom FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 4) 재고 이동 이력 (입고/출고/조정/재고조사 반영) -- item_code 는 낱개(MT-/MX-/MZ-)만 저장한다. MY-/MD- 는 movement 금지. -- qty 부호 규칙: -- IN/OUT → 양수만(방향은 movement_type 으로 구분) -- ADJUST → 음수 허용(±) -- STOCKTAKE → 음수 허용(±, 재고조사 차이 반영분) -- 현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE) -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS stock_movement ( id BIGSERIAL PRIMARY KEY, movement_date DATE NOT NULL, warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code), item_code TEXT NOT NULL CHECK ( item_code LIKE 'MT-%' OR item_code LIKE 'MX-%' OR item_code LIKE 'MZ-%'), movement_type TEXT NOT NULL CHECK (movement_type IN ('IN', 'OUT', 'ADJUST', 'STOCKTAKE')), qty INTEGER NOT NULL, ref_type TEXT NOT NULL DEFAULT '', -- 예: shopee/lazada/stocktake/manual ref_no TEXT NOT NULL DEFAULT '', -- 외부 주문번호/조사 id 등 memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- IN/OUT 은 양수만. ADJUST/STOCKTAKE 만 음수 허용. CHECK ( (movement_type IN ('IN', 'OUT') AND qty > 0) OR movement_type IN ('ADJUST', 'STOCKTAKE') ) ); CREATE INDEX IF NOT EXISTS idx_stock_mv_wh_item ON stock_movement (warehouse_code, item_code); CREATE INDEX IF NOT EXISTS idx_stock_mv_date ON stock_movement (movement_date); CREATE INDEX IF NOT EXISTS idx_stock_mv_type ON stock_movement (movement_type); CREATE INDEX IF NOT EXISTS idx_stock_mv_ref ON stock_movement (ref_type, ref_no); -- ════════════════════════════════════════════════════════════ -- 5) 일일 재고조사 (헤더) -- 같은 날짜 + 같은 창고에 finalized 는 1건만 허용(부분 유니크 인덱스). -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS daily_stocktake ( id BIGSERIAL PRIMARY KEY, stocktake_date DATE NOT NULL, warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code), status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'finalized', 'cancelled')), memo TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), finalized_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_stocktake_date ON daily_stocktake (stocktake_date); CREATE INDEX IF NOT EXISTS idx_stocktake_wh ON daily_stocktake (warehouse_code); -- 같은 날짜+창고 finalized 중복 방지 CREATE UNIQUE INDEX IF NOT EXISTS uq_stocktake_finalized ON daily_stocktake (stocktake_date, warehouse_code) WHERE status = 'finalized'; DROP TRIGGER IF EXISTS trg_stocktake_updated ON daily_stocktake; CREATE TRIGGER trg_stocktake_updated BEFORE UPDATE ON daily_stocktake FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 6) 일일 재고조사 라인 -- sku_code: 낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD- 금지. -- 같은 조사 안에서 같은 sku_code 중복 금지. -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS daily_stocktake_line ( id BIGSERIAL PRIMARY KEY, stocktake_id BIGINT NOT NULL REFERENCES daily_stocktake(id) ON DELETE CASCADE, sku_code TEXT NOT NULL CHECK ( sku_code LIKE 'MT-%' OR sku_code LIKE 'MX-%' OR sku_code LIKE 'MZ-%' OR sku_code LIKE 'MY-%'), qty INTEGER NOT NULL CHECK (qty >= 0), memo TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (stocktake_id, sku_code) ); CREATE INDEX IF NOT EXISTS idx_stocktake_line_st ON daily_stocktake_line (stocktake_id); DROP TRIGGER IF EXISTS trg_stocktake_line_updated ON daily_stocktake_line; CREATE TRIGGER trg_stocktake_line_updated BEFORE UPDATE ON daily_stocktake_line FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); -- ════════════════════════════════════════════════════════════ -- 6.5) 일일 재고조사 랙(rack) 입력 -- 창고 랙(A/B면, 행/열/분할 칸)별로 "어떤 SKU 가 한 박스당 몇 개씩 -- 몇 박스 있는지"를 기록한다. 칸 단위 입력 → 서버가 SKU 별 -- qty = SUM(units_per_box * box_count) 로 집계해 daily_stocktake_line 을 -- 재생성한다(랙이 라인의 단일 출처). 한 칸(파렛트)에 여러 SKU 가능. -- cell_code 예: 'A3-1-1' (면 A, 행 3, 열 1, 분할 1). -- sku_code: 낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD- 금지. -- ════════════════════════════════════════════════════════════ CREATE TABLE IF NOT EXISTS daily_stocktake_rack ( id BIGSERIAL PRIMARY KEY, stocktake_id BIGINT NOT NULL REFERENCES daily_stocktake(id) ON DELETE CASCADE, cell_code TEXT NOT NULL, -- 랙 위치(예: A3-1-1) line_no INTEGER NOT NULL DEFAULT 0, -- 칸 안 입력 순서 sku_code TEXT NOT NULL CHECK ( sku_code LIKE 'MT-%' OR sku_code LIKE 'MX-%' OR sku_code LIKE 'MZ-%' OR sku_code LIKE 'MY-%'), units_per_box INTEGER NOT NULL CHECK (units_per_box > 0), box_count INTEGER NOT NULL CHECK (box_count > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_stocktake_rack_st ON daily_stocktake_rack (stocktake_id); CREATE INDEX IF NOT EXISTS idx_stocktake_rack_cell ON daily_stocktake_rack (stocktake_id, cell_code); -- ════════════════════════════════════════════════════════════ -- 7) seed — 기본 창고 + 재고관리 대상 아이템 -- ════════════════════════════════════════════════════════════ INSERT INTO warehouses (warehouse_code, warehouse_name, country) VALUES ('MY-WH-01', 'Malaysia Warehouse', 'MY') ON CONFLICT (warehouse_code) DO NOTHING; -- 낱개 아이템 (MT-/MX-/MZ-). MD- 뚜껑은 의도적으로 제외. INSERT INTO malaysia_items (item_code, item_name, kind, sort_order) VALUES ('MT-0320', 'Miracle Container 320ml', 'individual', 1), ('MT-0550', 'Miracle Container 550ml', 'individual', 2), ('MT-0750', 'Miracle Container 750ml', 'individual', 3), ('MT-0950', 'Miracle Container 950ml', 'individual', 4), ('MT-1800', 'Miracle Container 1800ml', 'individual', 5), ('MT-2200', 'Miracle Container 2200ml', 'individual', 6), ('MT-3200', 'Miracle Container 3200ml', 'individual', 7), ('MT-5000', 'Miracle Container 5000ml', 'individual', 8), ('MX-0001', 'Miracle Bag 3 Types', 'individual', 9), ('MX-0003', 'Miracle Bag 5 pcs', 'individual', 10), ('MZ-0001', 'Miracle Zipperbag 2.5L', 'individual', 11), ('MZ-0002', 'Miracle Zipperbag 6.0L', 'individual', 12), ('MY-0001', 'Launching Special', 'set', 21), ('MY-0002', 'Starter Set', 'set', 22), ('MY-0003', 'Essential Set', 'set', 23), ('MY-0004', 'Premium Set', 'set', 24), ('MY-0005', 'Ultimate Set', 'set', 25), ('MY-9991', 'Combo 2.5L+6L', 'set', 26) ON CONFLICT (item_code) DO NOTHING; -- ════════════════════════════════════════════════════════════ -- 8) 권한 (malaysia_app: CRUD only) -- ════════════════════════════════════════════════════════════ GRANT USAGE ON SCHEMA public TO malaysia_app; GRANT SELECT, INSERT, UPDATE, DELETE ON warehouses, malaysia_items, set_bom, stock_movement, daily_stocktake, daily_stocktake_line, daily_stocktake_rack TO malaysia_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO malaysia_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO malaysia_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO malaysia_app; SELECT 'malaysia_stock_db ready' AS status;