refactor(schema): invert relationship between items and recipes

Move item_id to recipes table and drop recipe_id from items
Update backend queries and admin UI to reflect the new domain model
This commit is contained in:
2026-04-30 09:50:11 +08:00
parent be6902333d
commit b94450d3ba
4 changed files with 90 additions and 59 deletions

View File

@@ -48,23 +48,11 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
name text NOT NULL UNIQUE name text NOT NULL UNIQUE
); );
CREATE TABLE IF NOT EXISTS recipes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS recipe_acquisition_methods (
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
PRIMARY KEY (recipe_id, acquisition_method_id)
);
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
category_id integer NOT NULL REFERENCES item_categories(id), category_id integer NOT NULL REFERENCES item_categories(id),
usage_id integer REFERENCES item_usages(id), usage_id integer REFERENCES item_usages(id),
recipe_id integer REFERENCES recipes(id),
dyeable boolean NOT NULL DEFAULT false, dyeable boolean NOT NULL DEFAULT false,
dual_dyeable boolean NOT NULL DEFAULT false, dual_dyeable boolean NOT NULL DEFAULT false,
pattern_editable boolean NOT NULL DEFAULT false pattern_editable boolean NOT NULL DEFAULT false
@@ -72,6 +60,49 @@ CREATE TABLE IF NOT EXISTS items (
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL; ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
CREATE TABLE IF NOT EXISTS recipes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
item_id integer NOT NULL UNIQUE REFERENCES items(id)
);
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS item_id integer REFERENCES items(id);
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'items'
AND column_name = 'recipe_id'
) THEN
EXECUTE '
UPDATE recipes r
SET item_id = linked.item_id
FROM (
SELECT DISTINCT ON (recipe_id) recipe_id, id AS item_id
FROM items
WHERE recipe_id IS NOT NULL
ORDER BY recipe_id, id
) linked
WHERE r.id = linked.recipe_id
AND r.item_id IS NULL
';
END IF;
END $$;
DELETE FROM recipes WHERE item_id IS NULL;
ALTER TABLE recipes ALTER COLUMN item_id SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS recipes_item_id_key ON recipes(item_id);
ALTER TABLE recipes DROP COLUMN IF EXISTS name;
ALTER TABLE items DROP COLUMN IF EXISTS recipe_id;
CREATE TABLE IF NOT EXISTS recipe_acquisition_methods (
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
PRIMARY KEY (recipe_id, acquisition_method_id)
);
DROP TABLE IF EXISTS item_item_tags; DROP TABLE IF EXISTS item_item_tags;
DROP TABLE IF EXISTS item_tags; DROP TABLE IF EXISTS item_tags;

View File

@@ -41,7 +41,6 @@ type ItemPayload = {
name: string; name: string;
categoryId: number; categoryId: number;
usageId: number | null; usageId: number | null;
recipeId: number | null;
dyeable: boolean; dyeable: boolean;
dualDyeable: boolean; dualDyeable: boolean;
patternEditable: boolean; patternEditable: boolean;
@@ -50,7 +49,7 @@ type ItemPayload = {
}; };
type RecipePayload = { type RecipePayload = {
name: string; itemId: number;
acquisitionMethodIds: number[]; acquisitionMethodIds: number[];
materials: IdQuantity[]; materials: IdQuantity[];
}; };
@@ -649,7 +648,7 @@ export async function getItem(id: number) {
` `
SELECT SELECT
r.id, r.id,
r.name, result_item.name,
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
FROM recipe_acquisition_methods ram FROM recipe_acquisition_methods ram
@@ -661,10 +660,11 @@ export async function getItem(id: number) {
FROM recipe_materials rm FROM recipe_materials rm
JOIN items mi ON mi.id = rm.item_id JOIN items mi ON mi.id = rm.item_id
WHERE rm.recipe_id = r.id WHERE rm.recipe_id = r.id
), '[]'::json) AS materials ), '[]'::json) AS materials,
FROM items i json_build_object('id', result_item.id, 'name', result_item.name) AS item
JOIN recipes r ON r.id = i.recipe_id FROM recipes r
WHERE i.id = $1 JOIN items result_item ON result_item.id = r.item_id
WHERE r.item_id = $1
`, `,
[id] [id]
), ),
@@ -684,9 +684,6 @@ export async function getItem(id: number) {
} }
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload { function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined
? null
: requirePositiveInteger(payload.recipeId, '请选择材料单');
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
? null ? null
: requirePositiveInteger(payload.usageId, '请选择用途'); : requirePositiveInteger(payload.usageId, '请选择用途');
@@ -695,7 +692,6 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
name: cleanName(payload.name, '请输入物品名字'), name: cleanName(payload.name, '请输入物品名字'),
categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'), categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'),
usageId, usageId,
recipeId,
dyeable: Boolean(payload.dyeable), dyeable: Boolean(payload.dyeable),
dualDyeable: Boolean(payload.dualDyeable), dualDyeable: Boolean(payload.dualDyeable),
patternEditable: Boolean(payload.patternEditable), patternEditable: Boolean(payload.patternEditable),
@@ -729,15 +725,14 @@ export async function createItem(payload: Record<string, unknown>) {
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
` `
INSERT INTO items (name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable) INSERT INTO items (name, category_id, usage_id, dyeable, dual_dyeable, pattern_editable)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id RETURNING id
`, `,
[ [
cleanPayload.name, cleanPayload.name,
cleanPayload.categoryId, cleanPayload.categoryId,
cleanPayload.usageId, cleanPayload.usageId,
cleanPayload.recipeId,
cleanPayload.dyeable, cleanPayload.dyeable,
cleanPayload.dualDyeable, cleanPayload.dualDyeable,
cleanPayload.patternEditable cleanPayload.patternEditable
@@ -760,17 +755,15 @@ export async function updateItem(id: number, payload: Record<string, unknown>) {
SET name = $1, SET name = $1,
category_id = $2, category_id = $2,
usage_id = $3, usage_id = $3,
recipe_id = $4, dyeable = $4,
dyeable = $5, dual_dyeable = $5,
dual_dyeable = $6, pattern_editable = $6
pattern_editable = $7 WHERE id = $7
WHERE id = $8
`, `,
[ [
cleanPayload.name, cleanPayload.name,
cleanPayload.categoryId, cleanPayload.categoryId,
cleanPayload.usageId, cleanPayload.usageId,
cleanPayload.recipeId,
cleanPayload.dyeable, cleanPayload.dyeable,
cleanPayload.dualDyeable, cleanPayload.dualDyeable,
cleanPayload.patternEditable, cleanPayload.patternEditable,
@@ -795,7 +788,7 @@ export async function listRecipes() {
return query(` return query(`
SELECT SELECT
r.id, r.id,
r.name, result_item.name,
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name) SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
FROM recipe_materials rm FROM recipe_materials rm
@@ -803,7 +796,8 @@ export async function listRecipes() {
WHERE rm.recipe_id = r.id WHERE rm.recipe_id = r.id
), '[]'::json) AS materials ), '[]'::json) AS materials
FROM recipes r FROM recipes r
ORDER BY r.name JOIN items result_item ON result_item.id = r.item_id
ORDER BY result_item.name
`); `);
} }
@@ -812,7 +806,7 @@ export async function getRecipe(id: number) {
` `
SELECT SELECT
r.id, r.id,
r.name, result_item.name,
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
FROM recipe_acquisition_methods ram FROM recipe_acquisition_methods ram
@@ -824,8 +818,10 @@ export async function getRecipe(id: number) {
FROM recipe_materials rm FROM recipe_materials rm
JOIN items i ON i.id = rm.item_id JOIN items i ON i.id = rm.item_id
WHERE rm.recipe_id = r.id WHERE rm.recipe_id = r.id
), '[]'::json) AS materials ), '[]'::json) AS materials,
json_build_object('id', result_item.id, 'name', result_item.name) AS item
FROM recipes r FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
WHERE r.id = $1 WHERE r.id = $1
`, `,
[id] [id]
@@ -834,7 +830,7 @@ export async function getRecipe(id: number) {
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload { function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
return { return {
name: cleanName(payload.name, '请输入材料单名字'), itemId: requirePositiveInteger(payload.itemId, '请选择物品'),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
materials: cleanQuantities(payload.materials) materials: cleanQuantities(payload.materials)
}; };
@@ -860,12 +856,20 @@ async function replaceRecipeRelations(client: DbClient, recipeId: number, payloa
} }
} }
async function ensureItemExists(client: DbClient, itemId: number): Promise<void> {
const result = await client.query('SELECT 1 FROM items WHERE id = $1', [itemId]);
if (result.rowCount === 0) {
throw validationError('请选择物品');
}
}
export async function createRecipe(payload: Record<string, unknown>) { export async function createRecipe(payload: Record<string, unknown>) {
const cleanPayload = cleanRecipePayload(payload); const cleanPayload = cleanRecipePayload(payload);
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>('INSERT INTO recipes (name) VALUES ($1) RETURNING id', [ await ensureItemExists(client, cleanPayload.itemId);
cleanPayload.name const result = await client.query<{ id: number }>('INSERT INTO recipes (item_id) VALUES ($1) RETURNING id', [
cleanPayload.itemId
]); ]);
const recipeId = result.rows[0].id; const recipeId = result.rows[0].id;
await replaceRecipeRelations(client, recipeId, cleanPayload); await replaceRecipeRelations(client, recipeId, cleanPayload);
@@ -878,7 +882,8 @@ export async function updateRecipe(id: number, payload: Record<string, unknown>)
const cleanPayload = cleanRecipePayload(payload); const cleanPayload = cleanRecipePayload(payload);
const updated = await withTransaction(async (client) => { const updated = await withTransaction(async (client) => {
const result = await client.query('UPDATE recipes SET name = $1 WHERE id = $2', [cleanPayload.name, id]); await ensureItemExists(client, cleanPayload.itemId);
const result = await client.query('UPDATE recipes SET item_id = $1 WHERE id = $2', [cleanPayload.itemId, id]);
if (result.rowCount === 0) { if (result.rowCount === 0) {
return false; return false;
} }

View File

@@ -71,6 +71,7 @@ export interface Recipe {
export interface RecipeDetail extends Recipe { export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[]; acquisition_methods: NamedEntity[];
item: NamedEntity;
} }
export interface Options { export interface Options {
@@ -105,7 +106,6 @@ export interface ItemPayload {
name: string; name: string;
categoryId: number; categoryId: number;
usageId: number | null; usageId: number | null;
recipeId: number | null;
dyeable: boolean; dyeable: boolean;
dualDyeable: boolean; dualDyeable: boolean;
patternEditable: boolean; patternEditable: boolean;
@@ -114,7 +114,7 @@ export interface ItemPayload {
} }
export interface RecipePayload { export interface RecipePayload {
name: string; itemId: number;
acquisitionMethodIds: number[]; acquisitionMethodIds: number[];
materials: Array<{ itemId: number; quantity: number }>; materials: Array<{ itemId: number; quantity: number }>;
} }

View File

@@ -70,7 +70,6 @@ const itemForm = ref({
name: '', name: '',
categoryId: '', categoryId: '',
usageId: '', usageId: '',
recipeId: '',
dyeable: false, dyeable: false,
dualDyeable: false, dualDyeable: false,
patternEditable: false, patternEditable: false,
@@ -79,7 +78,7 @@ const itemForm = ref({
}); });
const recipeForm = ref({ const recipeForm = ref({
id: 0, id: 0,
name: '', itemId: '',
acquisitionMethodIds: [] as string[], acquisitionMethodIds: [] as string[],
materials: [] as Array<{ itemId: string; quantity: number }> materials: [] as Array<{ itemId: string; quantity: number }>
}); });
@@ -255,7 +254,6 @@ function resetItemForm() {
name: '', name: '',
categoryId: '', categoryId: '',
usageId: '', usageId: '',
recipeId: '',
dyeable: false, dyeable: false,
dualDyeable: false, dualDyeable: false,
patternEditable: false, patternEditable: false,
@@ -272,7 +270,6 @@ async function editItem(item: Item) {
name: detail.name, name: detail.name,
categoryId: String(detail.category.id), categoryId: String(detail.category.id),
usageId: detail.usage ? String(detail.usage.id) : '', usageId: detail.usage ? String(detail.usage.id) : '',
recipeId: detail.recipe ? String(detail.recipe.id) : '',
dyeable: detail.customization.dyeable, dyeable: detail.customization.dyeable,
dualDyeable: detail.customization.dualDyeable, dualDyeable: detail.customization.dualDyeable,
patternEditable: detail.customization.patternEditable, patternEditable: detail.customization.patternEditable,
@@ -288,7 +285,6 @@ async function saveItem() {
name: itemForm.value.name, name: itemForm.value.name,
categoryId: Number(itemForm.value.categoryId), categoryId: Number(itemForm.value.categoryId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null, usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
recipeId: itemForm.value.recipeId ? Number(itemForm.value.recipeId) : null,
dyeable: itemForm.value.dyeable, dyeable: itemForm.value.dyeable,
dualDyeable: itemForm.value.dualDyeable, dualDyeable: itemForm.value.dualDyeable,
patternEditable: itemForm.value.patternEditable, patternEditable: itemForm.value.patternEditable,
@@ -313,7 +309,7 @@ async function removeItem(id: number) {
} }
function resetRecipeForm() { function resetRecipeForm() {
recipeForm.value = { id: 0, name: '', acquisitionMethodIds: [], materials: [] }; recipeForm.value = { id: 0, itemId: '', acquisitionMethodIds: [], materials: [] };
} }
function addRecipeMaterial() { function addRecipeMaterial() {
@@ -325,7 +321,7 @@ async function editRecipe(item: Recipe) {
const detail = await api.recipeDetail(item.id); const detail = await api.recipeDetail(item.id);
recipeForm.value = { recipeForm.value = {
id: detail.id, id: detail.id,
name: detail.name, itemId: String(detail.item.id),
acquisitionMethodIds: detail.acquisition_methods.map((method) => String(method.id)), acquisitionMethodIds: detail.acquisition_methods.map((method) => String(method.id)),
materials: detail.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity })) materials: detail.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity }))
}; };
@@ -335,7 +331,7 @@ async function editRecipe(item: Recipe) {
async function saveRecipe() { async function saveRecipe() {
await run(async () => { await run(async () => {
const payload: RecipePayload = { const payload: RecipePayload = {
name: recipeForm.value.name, itemId: Number(recipeForm.value.itemId),
acquisitionMethodIds: toIds(recipeForm.value.acquisitionMethodIds), acquisitionMethodIds: toIds(recipeForm.value.acquisitionMethodIds),
materials: toQuantityRows(recipeForm.value.materials) materials: toQuantityRows(recipeForm.value.materials)
}; };
@@ -345,7 +341,7 @@ async function saveRecipe() {
await api.createRecipe(payload); await api.createRecipe(payload);
} }
resetRecipeForm(); resetRecipeForm();
await loadRecipes(); await Promise.all([loadRecipes(), loadItems()]);
}); });
} }
@@ -576,13 +572,6 @@ onMounted(() => {
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option> <option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
</select> </select>
</div> </div>
<div class="field">
<label for="item-recipe">材料单</label>
<select id="item-recipe" v-model="itemForm.recipeId">
<option value=""></option>
<option v-for="item in recipeRows" :key="item.id" :value="item.id">{{ item.name }}</option>
</select>
</div>
<div class="check-row"> <div class="check-row">
<label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label> <label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label>
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> 可双区染色</label> <label><input v-model="itemForm.dualDyeable" type="checkbox" /> 可双区染色</label>
@@ -635,7 +624,13 @@ onMounted(() => {
<section v-if="activeTab === 'recipes' && options" class="admin-layout"> <section v-if="activeTab === 'recipes' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveRecipe"> <form class="detail-section" @submit.prevent="saveRecipe">
<h2>材料单</h2> <h2>材料单</h2>
<div class="field"><label for="recipe-name">名称</label><input id="recipe-name" v-model="recipeForm.name" /></div> <div class="field">
<label for="recipe-item">物品</label>
<select id="recipe-item" v-model="recipeForm.itemId">
<option value="">请选择</option>
<option v-for="item in itemRows" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
</select>
</div>
<div class="field"> <div class="field">
<label for="recipe-methods">入手方式</label> <label for="recipe-methods">入手方式</label>
<TagsSelect <TagsSelect