feat(fulfillment): 建立正式履约影子模型

This commit is contained in:
yml2213
2026-11-05 21:07:11 +08:00
parent b424333a30
commit bd7dd4efcc
6 changed files with 1110 additions and 5 deletions
@@ -93,11 +93,100 @@ async function loadRecentDuplicateAttempts(): Promise<CountRow[]> {
return result.rows
}
async function loadShadowModelDiffs(): Promise<CountRow[]> {
const result = await query<{
missing_fulfillments: number
fulfillment_state_diffs: number
missing_attempts: number
missing_open_issues: number
unlinked_events: number
}>(
`
SELECT
COUNT(*) FILTER (WHERE fulfillment.id IS NULL)::int AS missing_fulfillments,
COUNT(*) FILTER (
WHERE fulfillment.id IS NOT NULL
AND ROW(
fulfillment.route_status,
fulfillment.channel,
fulfillment.carrier_status,
fulfillment.current_attempt_type,
fulfillment.current_attempt_source_id,
fulfillment.issue_code,
fulfillment.issue_category
) IS DISTINCT FROM ROW(
CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END,
state.intended_channel,
state.carrier_status,
state.current_attempt_type,
NULLIF(state.current_attempt_id, 0),
state.issue_code,
state.issue_category
)
)::int AS fulfillment_state_diffs,
(
SELECT COUNT(*)::int
FROM (
SELECT task.order_item_id
FROM fulfillment_tasks task
JOIN fulfillments fulfillment ON fulfillment.order_item_id = task.order_item_id
WHERE NOT EXISTS (
SELECT 1
FROM fulfillment_attempts attempt
WHERE attempt.attempt_type = 'task'
AND attempt.source_id = task.id
)
UNION ALL
SELECT work_order.order_item_id
FROM work_orders work_order
JOIN fulfillments fulfillment ON fulfillment.order_item_id = work_order.order_item_id
WHERE work_order.order_item_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM fulfillment_attempts attempt
WHERE attempt.attempt_type = 'work_order'
AND attempt.source_id = work_order.id
)
) missing
)::int AS missing_attempts,
COUNT(*) FILTER (
WHERE state.issue_code <> ''
AND NOT EXISTS (
SELECT 1
FROM fulfillment_issues issue
WHERE issue.fulfillment_id = fulfillment.id
AND issue.issue_code = state.issue_code
AND issue.status <> 'resolved'
)
)::int AS missing_open_issues,
(
SELECT COUNT(*)::int
FROM fulfillment_state_events event
JOIN v_fulfillment_item_state active_state
ON active_state.order_item_id = event.order_item_id
WHERE event.fulfillment_id IS NULL
) AS unlinked_events
FROM v_fulfillment_item_state state
LEFT JOIN fulfillments fulfillment
ON fulfillment.order_item_id = state.order_item_id
`,
)
const row = result.rows[0]
return [
{ label: '影子履约缺失', count: Number(row?.missing_fulfillments || 0) },
{ label: '影子履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) },
{ label: '影子执行尝试缺失', count: Number(row?.missing_attempts || 0) },
{ label: '影子开放异常缺失', count: Number(row?.missing_open_issues || 0) },
{ label: '履约事件未关联影子记录', count: Number(row?.unlinked_events || 0) },
]
}
async function main(): Promise<void> {
const [counts, issues, duplicates] = await Promise.all([
const [counts, issues, duplicates, shadowDiffs] = await Promise.all([
loadCounts(),
loadIssueBreakdown(),
loadRecentDuplicateAttempts(),
loadShadowModelDiffs(),
])
console.info('[fulfillment-audit] 总览')
@@ -119,13 +208,20 @@ async function main(): Promise<void> {
console.info(` ${row.label}: ${row.count}`)
}
console.info('[fulfillment-audit] 影子模型差异')
for (const row of shadowDiffs) {
console.info(` ${row.label}: ${row.count}`)
}
const critical = new Map(counts.map((row) => [row.label, Number(row.count || 0)]))
const duplicateCount = Number(duplicates[0]?.count || 0)
const shadowDiffCount = shadowDiffs.reduce((sum, row) => sum + Number(row.count || 0), 0)
const hasCritical =
(critical.get('状态不一致') || 0) > 0 ||
(critical.get('售后关闭仍进入履约队列') || 0) > 0 ||
(critical.get('已分配但无有效载体') || 0) > 0 ||
duplicateCount > 0
duplicateCount > 0 ||
shadowDiffCount > 0
if (strict && hasCritical) {
process.exitCode = 1
@@ -0,0 +1,810 @@
-- 141_fulfillment_shadow_model.sql
--
-- 正式履约影子模型:
-- 1. fulfillments 保存订单项当前履约事实;
-- 2. fulfillment_attempts 保存任务/工单的执行尝试;
-- 3. fulfillment_issues 保存当前结构化异常,并保留已解决历史;
-- 4. 现有 order_items / fulfillment_tasks / work_orders 继续作为真源,
-- 通过触发器双写影子表,读路径暂不切换。
-- 原视图把 route_status 错写成“内部 SKU 是否有配置”,导致已经明确指定渠道的
-- 订单项仍被标记为 missing_channel。保留原视图为 raw 层,外层按“已有渠道即已路由”
-- 修正路由口径,原视图名和字段保持不变。
ALTER VIEW v_fulfillment_item_state RENAME TO v_fulfillment_item_state_raw;
CREATE OR REPLACE VIEW v_fulfillment_item_state AS
SELECT
raw.order_item_id,
raw.order_id,
raw.local_order_id,
raw.provider,
raw.platform,
raw.shop_id,
raw.shop_name,
raw.platform_order_id,
raw.pay_status,
raw.order_status,
raw.fulfillment_status,
raw.internal_sku,
raw.intended_channel,
raw.stored_channel,
raw.configured_channel,
CASE
WHEN COALESCE(raw.route_status, '') = 'missing_internal_sku' THEN 'missing_internal_sku'
WHEN raw.intended_channel IS NOT NULL THEN 'configured'
ELSE 'missing_channel'
END AS route_status,
raw.carrier_status,
raw.current_attempt_type,
raw.current_attempt_id,
raw.current_attempt_status,
raw.current_attempt_result_code,
raw.latest_task_id,
raw.latest_task_executor_key,
raw.latest_task_status,
raw.latest_task_result_code,
raw.latest_work_order_id,
raw.latest_work_order_status,
CASE
WHEN raw.route_status = 'missing_channel'
AND raw.intended_channel IS NOT NULL
THEN ''
ELSE raw.issue_code
END AS issue_code,
CASE
WHEN raw.route_status = 'missing_channel'
AND raw.intended_channel IS NOT NULL
THEN ''
ELSE raw.issue_category
END AS issue_category,
CASE
WHEN raw.route_status = 'missing_channel'
AND raw.intended_channel IS NOT NULL
THEN FALSE
ELSE raw.retryable
END AS retryable,
CASE
WHEN raw.route_status = 'missing_channel'
AND raw.intended_channel IS NOT NULL
THEN FALSE
ELSE raw.auto_retryable
END AS auto_retryable,
CASE
WHEN raw.route_status = 'missing_channel'
AND raw.intended_channel IS NOT NULL
THEN ''
ELSE raw.owner_role
END AS owner_role,
raw.fulfillment_locked,
raw.lock_reason,
raw.fulfillment_pending_reason,
raw.fulfillment_pending_detail,
raw.fulfillment_source,
raw.sku_code,
raw.sku_name,
raw.quantity,
raw.revenue_amount_fen,
raw.created_at,
raw.updated_at,
raw.age_minutes
FROM v_fulfillment_item_state_raw raw;
COMMENT ON VIEW v_fulfillment_item_state IS
'履约统一读模型:每订单项一行;明确渠道即视为已路由,渠道配置缺失只作为异常明细';
CREATE TABLE IF NOT EXISTS fulfillments (
id BIGSERIAL PRIMARY KEY,
order_item_id BIGINT NOT NULL UNIQUE REFERENCES order_items(id) ON DELETE CASCADE,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
internal_sku TEXT NOT NULL DEFAULT '',
route_status TEXT NOT NULL DEFAULT 'unrouted'
CHECK (route_status IN ('unrouted', 'configured')),
channel TEXT
CHECK (
channel IS NULL
OR channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account')
),
carrier_status TEXT NOT NULL DEFAULT 'missing'
CHECK (carrier_status IN ('not_required', 'missing', 'creating', 'ready', 'failed', 'terminal')),
current_attempt_type TEXT NOT NULL DEFAULT ''
CHECK (current_attempt_type IN ('', 'task', 'work_order')),
current_attempt_source_id BIGINT,
current_attempt_status TEXT NOT NULL DEFAULT '',
lock_reason TEXT NOT NULL DEFAULT '',
issue_code TEXT NOT NULL DEFAULT '',
issue_category TEXT NOT NULL DEFAULT '',
retryable BOOLEAN NOT NULL DEFAULT FALSE,
owner_role TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CHECK (
(current_attempt_type = '' AND current_attempt_source_id IS NULL)
OR (current_attempt_type <> '' AND current_attempt_source_id IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_fulfillments_order
ON fulfillments (order_id, updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_fulfillments_issue
ON fulfillments (issue_category, carrier_status, updated_at DESC)
WHERE issue_code <> '';
CREATE INDEX IF NOT EXISTS idx_fulfillments_channel
ON fulfillments (channel, carrier_status, updated_at DESC);
COMMENT ON TABLE fulfillments IS
'履约主记录:每订单项一行,拆分路由、渠道、载体、当前尝试和当前异常摘要';
CREATE TABLE IF NOT EXISTS fulfillment_attempts (
id BIGSERIAL PRIMARY KEY,
fulfillment_id BIGINT NOT NULL REFERENCES fulfillments(id) ON DELETE CASCADE,
unit_index INTEGER NOT NULL DEFAULT 1,
attempt_no INTEGER NOT NULL,
attempt_type TEXT NOT NULL CHECK (attempt_type IN ('task', 'work_order')),
source_id BIGINT NOT NULL,
source_attempt_no INTEGER NOT NULL DEFAULT 1,
executor_key TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
result_code TEXT NOT NULL DEFAULT '',
result_message TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMPTZ,
is_current BOOLEAN NOT NULL DEFAULT FALSE,
context_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (attempt_type, source_id),
UNIQUE (fulfillment_id, unit_index, attempt_no),
CHECK (attempt_no > 0),
CHECK (unit_index > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_fulfillment_attempts_current_unit
ON fulfillment_attempts (fulfillment_id, unit_index)
WHERE is_current = TRUE;
CREATE INDEX IF NOT EXISTS idx_fulfillment_attempts_history
ON fulfillment_attempts (fulfillment_id, unit_index, attempt_no DESC);
CREATE INDEX IF NOT EXISTS idx_fulfillment_attempts_status
ON fulfillment_attempts (attempt_type, status, updated_at DESC);
COMMENT ON TABLE fulfillment_attempts IS
'履约执行尝试:任务和接单工单的追加式历史,同一订单项单元仅一个当前尝试';
CREATE TABLE IF NOT EXISTS fulfillment_issues (
id BIGSERIAL PRIMARY KEY,
fulfillment_id BIGINT NOT NULL REFERENCES fulfillments(id) ON DELETE CASCADE,
issue_code TEXT NOT NULL,
issue_category TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'warning'
CHECK (severity IN ('info', 'warning', 'error', 'critical')),
source TEXT NOT NULL DEFAULT 'read_model',
retryable BOOLEAN NOT NULL DEFAULT FALSE,
auto_retryable BOOLEAN NOT NULL DEFAULT FALSE,
owner_role TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'acknowledged', 'waiting', 'resolved')),
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
UNIQUE (fulfillment_id, issue_code)
);
CREATE INDEX IF NOT EXISTS idx_fulfillment_issues_queue
ON fulfillment_issues (status, issue_category, last_seen_at DESC)
WHERE status <> 'resolved';
CREATE INDEX IF NOT EXISTS idx_fulfillment_issues_code
ON fulfillment_issues (issue_code, status, last_seen_at DESC);
COMMENT ON TABLE fulfillment_issues IS
'履约结构化异常:当前开放异常按 issue_code 唯一,解决后保留历史';
ALTER TABLE fulfillment_state_events
ADD COLUMN IF NOT EXISTS fulfillment_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_fulfillment_state_events_fulfillment
ON fulfillment_state_events (fulfillment_id, id DESC)
WHERE fulfillment_id IS NOT NULL;
CREATE OR REPLACE FUNCTION sync_fulfillment_shadow(p_order_item_id BIGINT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
item_record RECORD;
state_record RECORD;
fulfillment_id_value BIGINT;
BEGIN
SELECT
oi.id,
oi.order_id,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
oi.created_at
INTO item_record
FROM order_items oi
WHERE oi.id = p_order_item_id;
IF NOT FOUND THEN
RETURN;
END IF;
SELECT state.*
INTO state_record
FROM v_fulfillment_item_state state
WHERE state.order_item_id = item_record.id;
IF FOUND THEN
INSERT INTO fulfillments (
order_item_id,
order_id,
internal_sku,
route_status,
channel,
carrier_status,
current_attempt_type,
current_attempt_source_id,
current_attempt_status,
lock_reason,
issue_code,
issue_category,
retryable,
owner_role,
created_at,
updated_at
) VALUES (
item_record.id,
item_record.order_id,
COALESCE(state_record.internal_sku, ''),
CASE
WHEN state_record.route_status = 'configured' THEN 'configured'
ELSE 'unrouted'
END,
state_record.intended_channel,
state_record.carrier_status,
state_record.current_attempt_type,
NULLIF(state_record.current_attempt_id, 0),
state_record.current_attempt_status,
state_record.lock_reason,
state_record.issue_code,
state_record.issue_category,
state_record.retryable,
state_record.owner_role,
item_record.created_at,
NOW()
)
ON CONFLICT (order_item_id) DO UPDATE SET
order_id = EXCLUDED.order_id,
internal_sku = EXCLUDED.internal_sku,
route_status = EXCLUDED.route_status,
channel = EXCLUDED.channel,
carrier_status = EXCLUDED.carrier_status,
current_attempt_type = EXCLUDED.current_attempt_type,
current_attempt_source_id = EXCLUDED.current_attempt_source_id,
current_attempt_status = EXCLUDED.current_attempt_status,
lock_reason = EXCLUDED.lock_reason,
issue_code = EXCLUDED.issue_code,
issue_category = EXCLUDED.issue_category,
retryable = EXCLUDED.retryable,
owner_role = EXCLUDED.owner_role,
updated_at = NOW()
RETURNING id INTO fulfillment_id_value;
IF state_record.issue_code <> '' THEN
INSERT INTO fulfillment_issues (
fulfillment_id,
issue_code,
issue_category,
severity,
source,
retryable,
auto_retryable,
owner_role,
status,
detail_json,
first_seen_at,
last_seen_at,
resolved_at
) VALUES (
fulfillment_id_value,
state_record.issue_code,
state_record.issue_category,
CASE
WHEN state_record.issue_category IN ('after_sales', 'system') THEN 'error'
WHEN state_record.issue_category = 'carrier' THEN 'warning'
ELSE 'info'
END,
'read_model',
state_record.retryable,
state_record.auto_retryable,
state_record.owner_role,
'open',
COALESCE(state_record.fulfillment_pending_detail, '{}'::jsonb),
state_record.created_at,
NOW(),
NULL
)
ON CONFLICT (fulfillment_id, issue_code) DO UPDATE SET
issue_category = EXCLUDED.issue_category,
severity = EXCLUDED.severity,
retryable = EXCLUDED.retryable,
auto_retryable = EXCLUDED.auto_retryable,
owner_role = EXCLUDED.owner_role,
status = 'open',
detail_json = EXCLUDED.detail_json,
last_seen_at = NOW(),
resolved_at = NULL;
UPDATE fulfillment_issues
SET status = 'resolved',
resolved_at = NOW()
WHERE fulfillment_id = fulfillment_id_value
AND issue_code <> state_record.issue_code
AND status <> 'resolved';
ELSE
UPDATE fulfillment_issues
SET status = 'resolved',
resolved_at = NOW()
WHERE fulfillment_id = fulfillment_id_value
AND status <> 'resolved';
END IF;
ELSE
INSERT INTO fulfillments (
order_item_id,
order_id,
internal_sku,
route_status,
channel,
carrier_status,
current_attempt_type,
current_attempt_source_id,
current_attempt_status,
lock_reason,
issue_code,
issue_category,
retryable,
owner_role,
created_at,
updated_at
) VALUES (
item_record.id,
item_record.order_id,
COALESCE(item_record.internal_sku, ''),
CASE WHEN COALESCE(item_record.internal_sku, '') = '' THEN 'unrouted' ELSE 'configured' END,
NULL,
'terminal',
'',
NULL,
'',
'',
'',
'',
FALSE,
'',
item_record.created_at,
NOW()
)
ON CONFLICT (order_item_id) DO UPDATE SET
route_status = EXCLUDED.route_status,
channel = NULL,
carrier_status = 'terminal',
current_attempt_type = '',
current_attempt_source_id = NULL,
current_attempt_status = '',
lock_reason = '',
issue_code = '',
issue_category = '',
retryable = FALSE,
owner_role = '',
updated_at = NOW()
RETURNING id INTO fulfillment_id_value;
UPDATE fulfillment_issues
SET status = 'resolved',
resolved_at = NOW()
WHERE fulfillment_id = fulfillment_id_value
AND status <> 'resolved';
END IF;
END;
$$;
-- 先为当前活跃订单项建立履约主记录和结构化异常。
SELECT sync_fulfillment_shadow(state.order_item_id)
FROM v_fulfillment_item_state state;
WITH source_attempts AS (
SELECT
task.order_item_id,
task.unit_index,
task.created_at AS occurred_at,
'task'::text AS attempt_type,
task.id AS source_id,
task.attempt_no AS source_attempt_no,
task.executor_key,
task.task_status AS status,
task.result_code,
task.result_message,
task.created_at AS started_at,
CASE
WHEN task.task_status IN (
'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled'
) THEN task.updated_at
ELSE NULL
END AS finished_at,
task.is_current,
task.context_json,
task.created_at,
task.updated_at
FROM fulfillment_tasks task
JOIN fulfillments fulfillment ON fulfillment.order_item_id = task.order_item_id
UNION ALL
SELECT
work_order.order_item_id,
1,
work_order.created_at,
'work_order'::text,
work_order.id,
1,
'work_order'::text,
work_order.status,
''::text,
work_order.problem_note,
work_order.created_at,
CASE
WHEN work_order.status IN ('accepted', 'cancelled', 'closed') THEN work_order.updated_at
ELSE NULL
END,
work_order.status <> 'cancelled',
jsonb_build_object(
'workOrderNo', work_order.work_order_no,
'productName', work_order.product_name
),
work_order.created_at,
work_order.updated_at
FROM work_orders work_order
JOIN fulfillments fulfillment ON fulfillment.order_item_id = work_order.order_item_id
WHERE work_order.order_item_id IS NOT NULL
),
ranked AS (
SELECT
source_attempts.*,
ROW_NUMBER() OVER (
PARTITION BY order_item_id, unit_index
ORDER BY occurred_at, attempt_type, source_id
)::int AS attempt_no
FROM source_attempts
)
INSERT INTO fulfillment_attempts (
fulfillment_id,
unit_index,
attempt_no,
attempt_type,
source_id,
source_attempt_no,
executor_key,
status,
result_code,
result_message,
started_at,
finished_at,
is_current,
context_json,
created_at,
updated_at
)
SELECT
fulfillment.id,
ranked.unit_index,
ranked.attempt_no,
ranked.attempt_type,
ranked.source_id,
ranked.source_attempt_no,
ranked.executor_key,
ranked.status,
ranked.result_code,
ranked.result_message,
ranked.started_at,
ranked.finished_at,
CASE
WHEN ranked.attempt_type = fulfillment.current_attempt_type
AND ranked.source_id = fulfillment.current_attempt_source_id
THEN TRUE
ELSE FALSE
END,
ranked.context_json,
ranked.created_at,
ranked.updated_at
FROM ranked
JOIN fulfillments fulfillment ON fulfillment.order_item_id = ranked.order_item_id
ON CONFLICT (attempt_type, source_id) DO NOTHING;
UPDATE fulfillment_state_events event
SET fulfillment_id = fulfillment.id
FROM fulfillments fulfillment
WHERE fulfillment.order_item_id = event.order_item_id
AND event.fulfillment_id IS NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_fulfillment_state_events_fulfillment'
) THEN
ALTER TABLE fulfillment_state_events
ADD CONSTRAINT fk_fulfillment_state_events_fulfillment
FOREIGN KEY (fulfillment_id) REFERENCES fulfillments(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE OR REPLACE FUNCTION assign_fulfillment_event_shadow()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.fulfillment_id IS NULL THEN
SELECT fulfillment.id
INTO NEW.fulfillment_id
FROM fulfillments fulfillment
WHERE fulfillment.order_item_id = NEW.order_item_id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_fulfillment_state_events_shadow ON fulfillment_state_events;
CREATE TRIGGER trg_fulfillment_state_events_shadow
BEFORE INSERT ON fulfillment_state_events
FOR EACH ROW
EXECUTE FUNCTION assign_fulfillment_event_shadow();
CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_from_order_item()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM sync_fulfillment_shadow(NEW.id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow ON order_items;
CREATE TRIGGER trg_order_items_fulfillment_shadow
AFTER INSERT OR UPDATE OF
fulfillment_status,
fulfillment_channel,
fulfillment_source,
fulfillment_locked,
fulfillment_pending_reason,
fulfillment_pending_detail,
fulfillment_reason,
manual_sku_key,
sku_code,
sku_name
ON order_items
FOR EACH ROW
EXECUTE FUNCTION sync_fulfillment_shadow_from_order_item();
CREATE OR REPLACE FUNCTION sync_fulfillment_task_attempt()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
fulfillment_id_value BIGINT;
existing_attempt_id BIGINT;
next_attempt_no INTEGER;
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM fulfillment_attempts
WHERE attempt_type = 'task' AND source_id = OLD.id;
PERFORM sync_fulfillment_shadow(OLD.order_item_id);
RETURN OLD;
END IF;
PERFORM sync_fulfillment_shadow(NEW.order_item_id);
SELECT fulfillment.id
INTO fulfillment_id_value
FROM fulfillments fulfillment
WHERE fulfillment.order_item_id = NEW.order_item_id;
IF fulfillment_id_value IS NULL THEN
RETURN NEW;
END IF;
SELECT attempt.id
INTO existing_attempt_id
FROM fulfillment_attempts attempt
WHERE attempt.attempt_type = 'task'
AND attempt.source_id = NEW.id;
IF existing_attempt_id IS NULL THEN
SELECT COALESCE(MAX(attempt.attempt_no), 0) + 1
INTO next_attempt_no
FROM fulfillment_attempts attempt
WHERE attempt.fulfillment_id = fulfillment_id_value
AND attempt.unit_index = NEW.unit_index;
END IF;
UPDATE fulfillment_attempts
SET is_current = FALSE,
updated_at = NOW()
WHERE fulfillment_id = fulfillment_id_value
AND unit_index = NEW.unit_index
AND is_current = TRUE
AND NOT (attempt_type = 'task' AND source_id = NEW.id);
INSERT INTO fulfillment_attempts (
fulfillment_id,
unit_index,
attempt_no,
attempt_type,
source_id,
source_attempt_no,
executor_key,
status,
result_code,
result_message,
started_at,
finished_at,
is_current,
context_json,
created_at,
updated_at
) VALUES (
fulfillment_id_value,
NEW.unit_index,
COALESCE(next_attempt_no, 1),
'task',
NEW.id,
NEW.attempt_no,
NEW.executor_key,
NEW.task_status,
NEW.result_code,
NEW.result_message,
NEW.created_at,
CASE
WHEN NEW.task_status IN (
'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled'
) THEN NEW.updated_at
ELSE NULL
END,
NEW.is_current,
NEW.context_json,
NEW.created_at,
NEW.updated_at
)
ON CONFLICT (attempt_type, source_id) DO UPDATE SET
executor_key = EXCLUDED.executor_key,
status = EXCLUDED.status,
result_code = EXCLUDED.result_code,
result_message = EXCLUDED.result_message,
finished_at = EXCLUDED.finished_at,
is_current = EXCLUDED.is_current,
context_json = EXCLUDED.context_json,
updated_at = EXCLUDED.updated_at;
PERFORM sync_fulfillment_shadow(NEW.order_item_id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_fulfillment_tasks_shadow ON fulfillment_tasks;
CREATE TRIGGER trg_fulfillment_tasks_shadow
AFTER INSERT OR UPDATE OR DELETE ON fulfillment_tasks
FOR EACH ROW
EXECUTE FUNCTION sync_fulfillment_task_attempt();
CREATE OR REPLACE FUNCTION sync_fulfillment_work_order_attempt()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
fulfillment_id_value BIGINT;
existing_attempt_id BIGINT;
next_attempt_no INTEGER;
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM fulfillment_attempts
WHERE attempt_type = 'work_order' AND source_id = OLD.id;
IF OLD.order_item_id IS NOT NULL THEN
PERFORM sync_fulfillment_shadow(OLD.order_item_id);
END IF;
RETURN OLD;
END IF;
IF NEW.order_item_id IS NULL THEN
RETURN NEW;
END IF;
PERFORM sync_fulfillment_shadow(NEW.order_item_id);
SELECT fulfillment.id
INTO fulfillment_id_value
FROM fulfillments fulfillment
WHERE fulfillment.order_item_id = NEW.order_item_id;
IF fulfillment_id_value IS NULL THEN
RETURN NEW;
END IF;
SELECT attempt.id
INTO existing_attempt_id
FROM fulfillment_attempts attempt
WHERE attempt.attempt_type = 'work_order'
AND attempt.source_id = NEW.id;
IF existing_attempt_id IS NULL THEN
SELECT COALESCE(MAX(attempt.attempt_no), 0) + 1
INTO next_attempt_no
FROM fulfillment_attempts attempt
WHERE attempt.fulfillment_id = fulfillment_id_value
AND attempt.unit_index = 1;
END IF;
UPDATE fulfillment_attempts
SET is_current = FALSE,
updated_at = NOW()
WHERE fulfillment_id = fulfillment_id_value
AND unit_index = 1
AND is_current = TRUE
AND NOT (attempt_type = 'work_order' AND source_id = NEW.id);
INSERT INTO fulfillment_attempts (
fulfillment_id,
unit_index,
attempt_no,
attempt_type,
source_id,
source_attempt_no,
executor_key,
status,
result_code,
result_message,
started_at,
finished_at,
is_current,
context_json,
created_at,
updated_at
) VALUES (
fulfillment_id_value,
1,
COALESCE(next_attempt_no, 1),
'work_order',
NEW.id,
1,
'work_order',
NEW.status,
'',
NEW.problem_note,
NEW.created_at,
CASE
WHEN NEW.status IN ('accepted', 'cancelled', 'closed') THEN NEW.updated_at
ELSE NULL
END,
NEW.status <> 'cancelled',
jsonb_build_object(
'workOrderNo', NEW.work_order_no,
'productName', NEW.product_name
),
NEW.created_at,
NEW.updated_at
)
ON CONFLICT (attempt_type, source_id) DO UPDATE SET
status = EXCLUDED.status,
result_message = EXCLUDED.result_message,
finished_at = EXCLUDED.finished_at,
is_current = EXCLUDED.is_current,
context_json = EXCLUDED.context_json,
updated_at = EXCLUDED.updated_at;
PERFORM sync_fulfillment_shadow(NEW.order_item_id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_work_orders_fulfillment_shadow ON work_orders;
CREATE TRIGGER trg_work_orders_fulfillment_shadow
AFTER INSERT OR UPDATE OR DELETE ON work_orders
FOR EACH ROW
EXECUTE FUNCTION sync_fulfillment_work_order_attempt();
COMMENT ON COLUMN fulfillment_state_events.fulfillment_id IS
'正式履约影子记录;迁移 141 后新旧事件都自动关联';
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const migration = readFileSync(
new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url),
'utf8',
)
const auditScript = readFileSync(
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
'utf8',
)
test('正式履约影子模型包含履约、尝试和异常三张表', () => {
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments'))
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts'))
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues'))
assert.ok(migration.includes('uq_fulfillment_attempts_current_unit'))
assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)'))
})
test('影子模型从现有真源回填并持续双写', () => {
assert.ok(migration.includes('SELECT sync_fulfillment_shadow(state.order_item_id)'))
assert.ok(migration.includes('trg_order_items_fulfillment_shadow'))
assert.ok(migration.includes('trg_fulfillment_tasks_shadow'))
assert.ok(migration.includes('trg_work_orders_fulfillment_shadow'))
assert.ok(migration.includes('assign_fulfillment_event_shadow'))
})
test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => {
assert.ok(migration.includes('v_fulfillment_item_state_raw'))
assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'"))
assert.ok(
migration.includes(
"WHEN raw.route_status = 'missing_channel'\n AND raw.intended_channel IS NOT NULL\n THEN ''",
),
)
})
test('审计脚本核对影子表、执行尝试、开放异常和事件关联', () => {
assert.ok(auditScript.includes('影子履约缺失'))
assert.ok(auditScript.includes('影子履约状态差异'))
assert.ok(auditScript.includes('影子执行尝试缺失'))
assert.ok(auditScript.includes('影子开放异常缺失'))
assert.ok(auditScript.includes('履约事件未关联影子记录'))
})
@@ -27,6 +27,7 @@ test(
let orderItemId = 0
let manualConfigId = 0
let productId = 0
let profileId = 0
try {
const order = await query<{ id: number }>(
@@ -51,6 +52,19 @@ test(
)
orderItemId = Number(item.rows[0]!.id)
const initialShadow = await query<{
route_status: string
carrier_status: string
issue_code: string
}>(
`SELECT route_status, carrier_status, issue_code
FROM fulfillments
WHERE order_item_id = $1`,
[orderItemId],
)
assert.equal(initialShadow.rows[0]?.route_status, 'unrouted')
assert.equal(initialShadow.rows[0]?.issue_code, 'missing_channel')
const pendingDetail = {
internalSku: `状态事件测试-${suffix}`,
channel: 'manual',
@@ -146,6 +160,33 @@ test(
assert.equal(events[0]?.previous_channel, null)
assert.equal(events[0]?.next_channel, 'third_party_voucher')
const assignedShadow = await query<{
route_status: string
channel: string
carrier_status: string
issue_code: string
event_count: number
}>(
`SELECT
f.route_status,
f.channel,
f.carrier_status,
f.issue_code,
(
SELECT count(*)::int
FROM fulfillment_state_events event
WHERE event.fulfillment_id = f.id
) AS event_count
FROM fulfillments f
WHERE f.order_item_id = $1`,
[orderItemId],
)
assert.equal(assignedShadow.rows[0]?.route_status, 'configured')
assert.equal(assignedShadow.rows[0]?.channel, 'third_party_voucher')
assert.equal(assignedShadow.rows[0]?.carrier_status, 'not_required')
assert.equal(assignedShadow.rows[0]?.issue_code, '')
assert.equal(Number(assignedShadow.rows[0]?.event_count), 3)
const product = await query<{ id: number }>(
`INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
@@ -193,6 +234,112 @@ test(
assert.equal(Number(configured[0]?.fulfillment_config_id), manualConfigId)
assert.equal(Number(configured[0]?.canonical_product_id), productId)
const profile = await query<{ id: number }>(
`INSERT INTO fulfillment_profiles (
profile_key, name, executor_key, requires_claim, auto_dispatch,
inventory_strategy, config_json, created_at, updated_at
) VALUES ($1, '状态事件任务', 'affiliate_dash', TRUE, FALSE,
'external_platform', '{}'::jsonb, NOW(), NOW())
RETURNING id`,
[`ff-state-profile-${suffix}`],
)
profileId = Number(profile.rows[0]!.id)
await query(
`INSERT INTO fulfillment_tasks (
order_id, order_item_id, unit_index, task_no, provider, platform,
shop_id, shop_name, platform_order_id, profile_id, executor_key,
task_status, attempt_no, is_current, created_at, updated_at
)
SELECT
item.order_id, item.id, 1, $2, source.provider, source.platform,
source.shop_id, source.shop_name, source.platform_order_id, $3, 'affiliate_dash',
'paid', 1, TRUE, NOW(), NOW()
FROM order_items item
JOIN orders source ON source.id = item.order_id
WHERE item.id = $1`,
[orderItemId, `FF-SHADOW-TASK-${suffix}`, profileId],
)
const attempts = await query<{
attempt_type: string
unit_index: number
attempt_no: number
source_id: number
is_current: boolean
}>(
`SELECT attempt_type, unit_index, attempt_no, source_id, is_current
FROM fulfillment_attempts
WHERE fulfillment_id = (
SELECT id FROM fulfillments WHERE order_item_id = $1
)
ORDER BY id`,
[orderItemId],
)
assert.equal(attempts.rows.length, 1)
assert.equal(attempts.rows[0]?.attempt_type, 'task')
assert.equal(attempts.rows[0]?.unit_index, 1)
assert.equal(attempts.rows[0]?.attempt_no, 1)
assert.equal(attempts.rows[0]?.is_current, true)
const shadowDiff = await query<{
fulfillment_state_diffs: number
missing_attempts: number
missing_open_issues: number
unlinked_events: number
}>(
`SELECT
COUNT(*) FILTER (
WHERE ROW(
fulfillment.route_status,
fulfillment.channel,
fulfillment.carrier_status,
fulfillment.current_attempt_type,
fulfillment.current_attempt_source_id,
fulfillment.issue_code,
fulfillment.issue_category
) IS DISTINCT FROM ROW(
CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END,
state.intended_channel,
state.carrier_status,
state.current_attempt_type,
NULLIF(state.current_attempt_id, 0),
state.issue_code,
state.issue_category
)
)::int AS fulfillment_state_diffs,
(
SELECT COUNT(*)::int
FROM fulfillment_tasks task
WHERE task.order_item_id = $1
AND NOT EXISTS (
SELECT 1 FROM fulfillment_attempts attempt
WHERE attempt.attempt_type = 'task' AND attempt.source_id = task.id
)
) AS missing_attempts,
COUNT(*) FILTER (
WHERE state.issue_code <> ''
AND NOT EXISTS (
SELECT 1 FROM fulfillment_issues issue
WHERE issue.fulfillment_id = fulfillment.id
AND issue.issue_code = state.issue_code
AND issue.status <> 'resolved'
)
)::int AS missing_open_issues,
(
SELECT COUNT(*)::int
FROM fulfillment_state_events event
WHERE event.order_item_id = $1
AND event.fulfillment_id IS NULL
) AS unlinked_events
FROM v_fulfillment_item_state state
JOIN fulfillments fulfillment ON fulfillment.order_item_id = state.order_item_id
WHERE state.order_item_id = $1`,
[orderItemId],
)
assert.equal(Number(shadowDiff.rows[0]?.fulfillment_state_diffs), 0)
assert.equal(Number(shadowDiff.rows[0]?.missing_attempts), 0)
assert.equal(Number(shadowDiff.rows[0]?.missing_open_issues), 0)
assert.equal(Number(shadowDiff.rows[0]?.unlinked_events), 0)
await assert.rejects(
query(`UPDATE fulfillment_state_events SET reason = 'tampered' WHERE order_item_id = $1`, [
orderItemId,
@@ -209,6 +356,9 @@ test(
if (productId > 0) {
await query(`DELETE FROM product_catalog WHERE id = $1`, [productId])
}
if (profileId > 0) {
await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [profileId])
}
if (orderId > 0) {
await query(`DELETE FROM orders WHERE id = $1`, [orderId])
}
+1 -1
View File
@@ -1,6 +1,6 @@
# order_site 文档索引
> 最后补充:2026-11-05,新增《履约系统架构复盘与优化方案》;生产代码基线 `e8b76b3b` / 迁移 `136`,开发分支 `dev_11.5_2` 已推进到迁移 `140`(履约统一状态写入口与追加事件)。
> 最后补充:2026-11-05,新增《履约系统架构复盘与优化方案》;生产代码基线 `e8b76b3b` / 迁移 `136`,开发分支 `dev_11.5_2` 已推进到迁移 `141`(履约统一状态、追加事件与正式影子模型)。
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。
> 本文是 `docs/` 的唯一入口。**整体财务流程看 [财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md)**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。
> 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
@@ -10,7 +10,7 @@ status: draft
> 状态:**方案**,尚未实施。
> 最后校准:2026-11-05。
> 代码基线:`e8b76b3b` + `dev_11.5_2` 履约架构改造。
> 数据库基线:生产迁移 `136`;开发分支迁移 `140`。
> 数据库基线:生产迁移 `136`;开发分支迁移 `141`。
> 生产核对方式:只读 SQL、后台页面、现行文档与源码交叉检查。
## 实施进度
@@ -31,7 +31,10 @@ status: draft
- 已完成:人工履约配置、标准商品关联、自动发货/接单平台商品回填、模板重置和调度补挂统一通过状态映射接口写入,并校验并发期间的原商品值。
- 已完成:任务创建先锁订单项并复用已有活跃尝试,避免并发调度重复插入同一当前尝试。
- 已完成:新增真实 PostgreSQL 状态事件集成测试;全新库 `001 → 140` 迁移和既有履约路由集成测试通过。
- 待继续:正式 `fulfillments / attempts / issues` 表、独立售后队列和生产存量修复。
- 已完成:迁移 `141` 建立 `fulfillments / fulfillment_attempts / fulfillment_issues` 影子模型,从现有读模型回填,并由订单项、任务、工单触发器持续双写。
- 已完成:审计脚本增加影子履约、尝试、开放异常和事件关联差异检查;当前读路径仍保持原表,未切换。
- 已完成:修正统一视图“明确渠道仍被判定为缺渠道”的旧口径,已有渠道直接视为已路由。
- 待继续:观察生产影子差异、切换读路径、停止旧字段写入、独立售后队列和生产存量修复。
### 历史数据边界