Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: top down product creation if no unit are created #109

Open
wants to merge 24 commits into
base: staging
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/server/migrations/0020_deleted_record_insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ const tables = [
"tag",
"test",
"unit",
"unit_relation",
39bytes marked this conversation as resolved.
Show resolved Hide resolved
"unit_revision",
"user",
"user_invite",
// "user_session",
"workspace",
"workspace_user",
];
Expand Down
271 changes: 246 additions & 25 deletions apps/server/src/db/part-variation.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import {
DB,
InsertPartVariation,
Part,
PartVariation,
PartVariationTreeNode,
PartVariationTreeRoot,
PartVariationUpdate,
} from "@cloud/shared";
import { PartVariationType } from "@cloud/shared/src/schemas/public/PartVariationType";
import { ExpressionBuilder, Kysely } from "kysely";
import { jsonObjectFrom } from "kysely/helpers/postgres";
import _ from "lodash";
import { Result, err, ok, safeTry } from "neverthrow";
import { markUpdatedAt } from "../db/query";
import { generateDatabaseId, tryQuery } from "../lib/db-utils";
import { db } from "./kysely";
import { getPart } from "./part";
import { fromTransaction, generateDatabaseId, tryQuery } from "../lib/db-utils";
import {
BadRequestError,
InternalServerError,
NotFoundError,
BadRequestError,
RouteError,
} from "../lib/error";
import { PartVariationType } from "@cloud/shared/src/schemas/public/PartVariationType";
import { jsonObjectFrom } from "kysely/helpers/postgres";
import { db } from "./kysely";
import { getPart } from "./part";
import { withUnitParent } from "./unit";

async function getOrCreateType(
db: Kysely<DB>,
Expand Down Expand Up @@ -87,27 +91,34 @@ async function getOrCreateMarket(
return ok(insertResult);
}

function validatePartNumber(part: Part, partNumber: string) {
const requiredPrefix = part.name + "-";
if (!partNumber.startsWith(requiredPrefix)) {
return err(
new BadRequestError(
`Part number must start with "${requiredPrefix}" for part ${part.name}`,
),
);
}
return ok(partNumber);
}

export async function createPartVariation(
db: Kysely<DB>,
input: InsertPartVariation,
): Promise<Result<PartVariation, RouteError>> {
const { components, ...newPartVariation } = input;
const { components, type: typeName, market: marketName, ...data } = input;

const part = await getPart(db, input.partId);
if (!part) {
return err(new NotFoundError("Part not found"));
}
const requiredPrefix = part.name + "-";
if (!newPartVariation.partNumber.startsWith(requiredPrefix)) {
return err(
new BadRequestError(
`Part number must start with "${requiredPrefix}" for part ${part.name}`,
),
);
}
const { type: typeName, market: marketName, ...data } = newPartVariation;

return safeTry(async function* () {
const partNumber = yield* validatePartNumber(
part,
input.partNumber,
).safeUnwrap();
let typeId: string | undefined = undefined;
let marketId: string | undefined = undefined;

Expand All @@ -131,6 +142,7 @@ export async function createPartVariation(
.values({
id: generateDatabaseId("part_variation"),
...data,
partNumber,
typeId,
marketId,
})
Expand Down Expand Up @@ -161,12 +173,17 @@ export async function createPartVariation(
});
}

export async function getPartVariation(partVariationId: string) {
export async function getPartVariation(
workspaceId: string,
partVariationId: string,
) {
return await db
.selectFrom("part_variation")
.selectAll()
.selectAll("part_variation")
.where("part_variation.id", "=", partVariationId)

.where("part_variation.workspaceId", "=", workspaceId)
.select((eb) => [withPartVariationType(eb)])
.select((eb) => [withPartVariationMarket(eb)])
.executeTakeFirst();
}

Expand All @@ -178,18 +195,29 @@ export async function getPartVariationComponents(partVariationId: string) {
.execute();
}

export async function getPartVariationUnits(partVariationId: string) {
return await db
.selectFrom("unit")
.selectAll("unit")
.where("unit.partVariationId", "=", partVariationId)
.select((eb) => withUnitParent(eb))
.execute();
}

type PartVariationEdge = {
partNumber: string;
partVariationId: string;
parentPartVariationId: string;
count: number;
description: string | null;
// depth: number;
LatentDream marked this conversation as resolved.
Show resolved Hide resolved
};

export async function getPartVariationTree(
async function getPartVariationTreeEdges(
db: Kysely<DB>,
partVariation: PartVariation,
): Promise<PartVariationTreeRoot> {
const edges = await db
) {
return await db
.withRecursive("part_variation_tree", (qb) =>
qb
.selectFrom("part_variation_relation as mr")
Expand All @@ -204,9 +232,10 @@ export async function getPartVariationTree(
"childPartVariationId as partVariationId",
"part_variation.partNumber",
"part_variation.description",
// sql<number>`1`.as("depth"),
])
.where("parentPartVariationId", "=", partVariation.id)
.unionAll((eb) =>
.union((eb) =>
eb
.selectFrom("part_variation_relation as mr")
.innerJoin(
Expand All @@ -225,22 +254,37 @@ export async function getPartVariationTree(
"mr.childPartVariationId as partVariationId",
"part_variation.partNumber",
"part_variation.description",
// sql<number>`depth + 1`.as("depth"),
LatentDream marked this conversation as resolved.
Show resolved Hide resolved
]),
),
)
.selectFrom("part_variation_tree")
.selectAll()
// .distinctOn(["parentPartVariationId", "partVariationId"])
.execute();
}

return buildPartVariationTree(partVariation, edges);
export async function getPartVariationTree(
db: Kysely<DB>,
partVariation: PartVariation,
): Promise<PartVariationTreeRoot> {
const edges = await getPartVariationTreeEdges(db, partVariation);
return buildPartVariationTree(
partVariation,
// _.sortBy(edges, (e) => e.depth),
LatentDream marked this conversation as resolved.
Show resolved Hide resolved
edges,
);
}

function buildPartVariationTree(
rootPartVariation: PartVariation,
edges: PartVariationEdge[],
) {
const nodes = new Map<string, PartVariationTreeNode>();
const root: PartVariationTreeRoot = { ...rootPartVariation, components: [] };
const root: PartVariationTreeRoot = {
...rootPartVariation,
components: [],
};

for (const edge of edges) {
const parent = nodes.get(edge.parentPartVariationId) ?? root;
Expand All @@ -266,6 +310,183 @@ function buildPartVariationTree(
return root;
}

async function getPartVariationImmediateChildren(
db: Kysely<DB>,
partVariationId: string,
) {
return await db
.selectFrom("part_variation_relation as mr")
.innerJoin("part_variation", "mr.childPartVariationId", "part_variation.id")
.select([
"parentPartVariationId",
"count",
"childPartVariationId as partVariationId",
"part_variation.partNumber",
"part_variation.description",
])
.where("parentPartVariationId", "=", partVariationId)
.execute();
}

async function haveComponentsChanged(
db: Kysely<DB>,
partVariationId: string,
components: { partVariationId: string; count: number }[],
) {
const curComponents = await getPartVariationImmediateChildren(
db,
partVariationId,
);
const makeObject = (cs: typeof components) => {
return Object.fromEntries(cs.map((c) => [c.partVariationId, c.count]));
};

const before = makeObject(curComponents);
const after = makeObject(components);

return !_.isEqual(before, after);
}

// Returns an error with the node of the cycle if one is detected, otherwise ok
function detectCycle(graph: PartVariationTreeRoot) {
const dfs = (
node: PartVariationTreeNode,
pathVertices: Set<string>,
): Result<void, { id: string; partNumber: string }> => {
if (pathVertices.has(node.id)) {
return err({ id: node.id, partNumber: node.partNumber });
}
if (node.components.length === 0) {
return ok(undefined);
}

pathVertices.add(node.id);
for (const c of node.components) {
const res = dfs(c.partVariation, pathVertices);
if (res.isErr()) {
return res;
}
}
pathVertices.delete(node.id);

return ok(undefined);
};

return dfs(graph, new Set());
}

export async function updatePartVariation(
db: Kysely<DB>,
partVariationId: string,
workspaceId: string,
update: PartVariationUpdate,
) {
const { components, type: typeName, market: marketName, ...data } = update;

const partVariation = await getPartVariation(workspaceId, partVariationId);
if (partVariation === undefined)
return err(new NotFoundError("Part variation not found"));

const part = await getPart(db, partVariation.partId);
if (part === undefined) return err(new NotFoundError("Part not found"));

const existingUnits = await getPartVariationUnits(partVariationId);
const componentsChanged = await haveComponentsChanged(
db,
partVariationId,
components,
);

// Don't allow users to change part structure if there are existing units
if (componentsChanged && existingUnits.length > 0) {
return err(
new BadRequestError(
"Cannot change part variation components because there are existing units",
),
);
}

return fromTransaction(async (tx) => {
return safeTry(async function* () {
let typeId: string | null = null;
let marketId: string | null = null;

if (typeName) {
const type = yield* (
await getOrCreateType(tx, typeName, workspaceId)
).safeUnwrap();
typeId = type.id;
}
if (marketName) {
const market = yield* (
await getOrCreateMarket(tx, marketName, workspaceId)
).safeUnwrap();
marketId = market.id;
}

yield* tryQuery(
tx
.updateTable("part_variation")
.set({
...data,
typeId,
marketId,
})
.where("id", "=", partVariationId)
.execute(),
).safeUnwrap();

const updatedPartVariation = await getPartVariation(
workspaceId,
partVariationId,
);
if (updatedPartVariation === undefined) {
return err(new InternalServerError("Failed to update part variation"));
}

if (!componentsChanged) {
return ok(undefined);
}

// Rebuild this level of the component tree
yield* tryQuery(
tx
.deleteFrom("part_variation_relation")
.where("parentPartVariationId", "=", partVariationId)
.execute(),
).safeUnwrap();

if (components.length > 0) {
yield* tryQuery(
tx
.insertInto("part_variation_relation")
.values(
components.map((c) => ({
parentPartVariationId: updatedPartVariation.id,
childPartVariationId: c.partVariationId,
workspaceId,
count: c.count,
})),
)
.execute(),
).safeUnwrap();

const graph = await getPartVariationTree(tx, updatedPartVariation);
yield* detectCycle(graph)
.mapErr(
(e) =>
new BadRequestError(
`Cycle detected in component graph at: ${e.partNumber}, not allowed`,
39bytes marked this conversation as resolved.
Show resolved Hide resolved
),
)
.safeUnwrap();
}

return ok(undefined);
});
});
}

export function withPartVariationType(
eb: ExpressionBuilder<DB, "part_variation">,
) {
Expand Down
Loading
Loading